Polish nav, transitions, layout, SEO, a11y, LaTeX, and Lighthouse CI

Nav bar & layout consistency
- Base.astro's `wide` layout now defaults on for every page (was opt-in),
  fixing the nav bar wrapping differently depending on page width
- Icon buttons for search and theme toggle (magnifying glass, sun/moon),
  replacing the plain "Search" text link and the "◐" glyph
- Search is now a Cmd/Ctrl+K modal over Pagefind (lazy-loaded on first
  open) instead of a separate /search/ page, matching the old site
- Fixed the article page's TOC sitting with unwanted left padding relative
  to the header (grid was center-justified instead of start-justified)

Smooth navigation
- Added Astro's ClientRouter for animated page transitions
- Every page-local script that only ran once at module load (three.js
  hero, minimap, comments, book-spine carousel, scroll-to-top, reading
  progress, theme toggle, Pagefind init) now re-initializes via
  astro:page-load, with AbortController-based cleanup so window/document
  listeners don't accumulate across client-side navigations

Restored from the legacy Zola site
- Reading progress bar (article pages) and scroll-to-top button
- Share buttons (X, Bluesky, LinkedIn, Reddit, HN, email) on writings
- Prev/next post navigation with the old label/title layout
- Utterances comments, wired to andwati/andwati.com with dark/light
  theme sync
- YouTube embeds: any plain <iframe src="...youtube-nocookie.com...">
  is now auto-wrapped in a responsive 16:9 container, gets
  loading="lazy", and emits VideoObject JSON-LD — no shortcode needed
- LaTeX: $inline$ and $$block$$ math renders to static HTML via KaTeX at
  build time (no client-side JS shipped for it)
- Tags page redesigned as a centered, alphabetical tag cloud

Removed the related-posts section per request.

Accessibility & contrast
- --accent (#d1502f) measured 4.0:1 on the light background, just under
  WCAG AA's 4.5:1 for normal text — darkened to #a84025 (5.7:1), plus
  matching light-mode-only fixes for the two hljs syntax colors and the
  tip/warning callout colors that had the same problem in light mode
- Fixed heading-order violations (h1 straight to h3 with nothing at h2)
  across the homepage, writings/portfolio/bookshelf/blogs lists, and tag
  pages — introduced a shared .entry-title class for the h2 list titles
- Fixed inconsistent title-link underline colors: a plain <a> carries its
  own default underline (in its own text color) instead of matching its
  parent heading's accent-colored one — h1/h2/h3 anchors now explicitly
  inherit the heading's decoration
- Bookshelf spine colors are generated per-title from a hash; the
  lightness-based text-color heuristic could still produce sub-4.5:1
  contrast for some hues (e.g. green) — now computes real WCAG contrast
  and adjusts

Performance
- The three.js homepage hero was the single largest main-thread cost
  site-wide (dragging that page's Lighthouse performance score to the low
  80s while every other page scored 95+): three.js is now dynamically
  imported only once the browser is idle (WebGL-support check split into
  its own tiny module so unsupported browsers never fetch it at all), the
  render loop is capped to ~30fps, and hover raycasting only runs on an
  actual pointer move instead of every frame

SEO & structured data
- Self-referential canonical links site-wide (with override support for
  posts first published elsewhere)
- Sitewide WebSite/Person JSON-LD, plus per-article BlogPosting,
  BreadcrumbList, and (when applicable) VideoObject JSON-LD
- robots.txt explicitly welcomes AI/LLM crawlers (GPTBot, ClaudeBot,
  PerplexityBot, Google-Extended, CCBot, etc.) and points at llms.txt
- Added an Atom feed (RSS and JSON Feed already existed)
- Sitemap now sets <lastmod> per entry from its own date/updated
  frontmatter instead of leaving every URL at build time

Analytics & comments infra
- Self-hosted Umami wiring via PUBLIC_UMAMI_SCRIPT_URL/WEBSITE_ID
  (apps/site/.env.example), no-ops until both are set
- sws.toml's CSP updated to allow utteranc.es and analytics.andwati.com —
  without this both were silently blocked in production

Lighthouse CI
- @lhci/cli wired up against the built site (mobile + desktop configs),
  `pnpm run lighthouse`, and a GitHub Actions workflow to run it on every
  push/PR — reporting only for now, no failing thresholds
This commit is contained in:
2026-09-12 20:40:16 +03:00
parent ffc3b6f353
commit 12dd32ea4f
44 changed files with 3194 additions and 370 deletions
+25
View File
@@ -0,0 +1,25 @@
name: Lighthouse
on:
push:
branches: [main, astro-migration]
pull_request:
workflow_dispatch:
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --filter site
- name: Run Lighthouse CI
run: pnpm --filter site run lighthouse
+15
View File
@@ -60,6 +60,21 @@ CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
[email protected]
```
Site analytics (read at build time by `apps/site`, so these must be set on
the `site` service before it builds, not just at runtime):
```dotenv
PUBLIC_UMAMI_SCRIPT_URL=https://analytics.andwati.com/script.js
PUBLIC_UMAMI_WEBSITE_ID=9eef8fba-c932-4488-ae23-6b84553e8c29
```
`Analytics.astro` silently no-ops if either is unset — safe to deploy without
them, but analytics won't record anything until both are present at build
time. `sws.toml`'s CSP already allows `analytics.andwati.com` and
`utteranc.es` (comments); if the Umami host ever changes, update both that
CSP and these two vars together, or the tracker will be silently blocked in
production instead of erroring visibly.
Generate each random secret independently; do not reuse the local development
values from `docker-compose.yml`.
+25 -11
View File
@@ -134,18 +134,21 @@ approved plan for full rationale on each decision referenced below.
- [x] Pagefind integration — indexes the build output (`pnpm build` runs
`astro build && pagefind --site dist`), `/search/` page using
Pagefind's default UI; verified with a real query in a browser
- [ ] Webmentions integration (replacing Utterances) — **plumbing only**:
`Base.astro` links `rel="webmention"` to a webmention.io endpoint for
`andwati.com`, but nothing displays received mentions yet, and the
endpoint won't receive anything until the domain is actually
registered/verified on webmention.io (**needs your input**: create an
account, verify domain ownership, then I can build the display side)
- [x] Comments: kept Utterances (not Webmentions) — `Comments.astro`, wired
to `andwati/andwati.com`, with the same dark/light theme-sync-via-
postMessage behavior the legacy site had, adapted to this site's
`data-theme` attribute. The `rel="webmention"` link was removed since
it's unused now. `sws.toml`'s CSP updated to allow `utteranc.es`
(script-src, frame-src) — without that the widget is silently blocked
in production
## Analytics
- [ ] Stand up Plausible or Umami on the Dokploy host, wire up tracking
snippet (replacing Google Analytics) — **needs your input**: requires
your Dokploy access, not something I can provision from the repo
- [x] Umami — self-hosted at `analytics.andwati.com`. `Analytics.astro`
renders the tracker script from `PUBLIC_UMAMI_SCRIPT_URL` +
`PUBLIC_UMAMI_WEBSITE_ID` (set in `apps/site/.env`, gitignored — copy
into the production environment's env vars too); `sws.toml`'s CSP
updated to allow that host in `script-src`/`connect-src`
## Migration from the legacy Zola site
@@ -192,5 +195,16 @@ approved plan for full rationale on each decision referenced below.
## Cleanup
- [ ] Remove Zola-specific files (`zola.toml`, `templates/`, `sass/`,
`themes/`, `content/posts/`, legacy OG/llms scripts) once each has a
working Astro/Strapi equivalent — progressively, not all at once
`themes/`, `static/`, `Makefile`, `bin/update-zola`, `content/posts/`,
`content/archive/_index.md`, `content/about.md`, `content/_index.md`,
legacy OG/llms scripts) — verified safe: root `Dockerfile` already
builds `apps/site` (not `zola build`), nothing in `apps/site` reads
those content files, and `content/writings/` has all 20 posts from
`content/posts/`. Root `package.json`'s `legacy:*` scripts already
removed. **Blocked**: the actual `git rm` was refused by the
permission system as irreversible destruction — run it yourself (fully
reversible via git history/revert since these are tracked files):
`git rm -r templates/ sass/ themes/ static/ zola.toml Makefile bin/
content/posts/ content/archive/_index.md content/about.md
content/_index.md scripts/generate-llms.mjs
scripts/generate-og-images.mjs`
+10
View File
@@ -0,0 +1,10 @@
# Self-hosted Umami analytics (https://umami.is/docs/tracker-configuration).
# Leave unset to disable analytics entirely — Analytics.astro no-ops until
# both of these are present. Fill in once the Umami instance is deployed.
PUBLIC_UMAMI_SCRIPT_URL=
PUBLIC_UMAMI_WEBSITE_ID=
# Optional: restrict tracking to these comma-separated domains (Umami's
# data-domains attribute), useful if the same build is deployed to a
# staging domain and you only want production traffic counted.
PUBLIC_UMAMI_DOMAINS=
+3
View File
@@ -17,6 +17,9 @@ pnpm-debug.log*
.env
.env.production
# Lighthouse CI report artifacts
.lighthouseci/
# macOS-specific files
.DS_Store
+18 -1
View File
@@ -1,10 +1,27 @@
// @ts-check
import sitemap from "@astrojs/sitemap";
import { defineConfig } from "astro/config";
import { buildLastmodMap, resolveContentDir } from "./lib-config/sitemap-lastmod.mjs";
import { siteConfig } from "./src/site.config.ts";
const lastmodMap = buildLastmodMap(resolveContentDir(import.meta.url));
// https://astro.build/config
export default defineConfig({
site: siteConfig.baseUrl,
integrations: [sitemap()],
integrations: [
sitemap({
// Every build re-crawls the actual rendered output, so the sitemap's
// URL list always matches whatever pages/content exist right now — no
// separate "regenerate the sitemap" step needed when content changes.
// On top of that, <lastmod> is set from each entry's own
// date/updated field (falling back to build time when unmapped),
// instead of every URL just getting "whenever the site was last
// built".
serialize(item) {
const lastmod = lastmodMap.get(new URL(item.url).pathname);
return lastmod ? { ...item, lastmod } : item;
},
}),
],
});
+65
View File
@@ -0,0 +1,65 @@
// Builds a Map<pathname, ISO date string> from content/ frontmatter so the
// sitemap's <lastmod> reflects real edit dates instead of just "whenever the
// site was last built". Runs from astro.config.mjs, which executes as plain
// Node outside Vite/Astro's content-layer pipeline (same reason
// scripts/generate-llms.astro-site.mjs parses frontmatter by hand instead of
// importing astro:content) — duplicated here rather than shared because the
// two scripts' output shapes don't overlap enough to be worth a shared module.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseToml } from "smol-toml";
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
function walk(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return entry.isFile() && entry.name.endsWith(".md") ? [full] : [];
});
}
function slugFor(dir, file) {
const rel = path.relative(dir, file).split(path.sep).join("/");
return rel.endsWith("/index.md") ? rel.slice(0, -"/index.md".length) : rel.slice(0, -".md".length);
}
function readEntries(typeDir) {
return walk(typeDir)
.map((file) => {
const raw = fs.readFileSync(file, "utf8");
const match = raw.match(FRONTMATTER_RE);
if (!match) return null;
return { slug: slugFor(typeDir, file), frontmatter: parseToml(match[1]) };
})
.filter(Boolean);
}
function isoOrNull(value) {
if (!value) return null;
const d = value instanceof Date ? value : new Date(value);
return Number.isNaN(d.valueOf()) ? null : d.toISOString();
}
export function buildLastmodMap(contentDir) {
const map = new Map();
for (const entry of readEntries(path.join(contentDir, "writings"))) {
if (entry.frontmatter.draft) continue;
const lastmod = isoOrNull(entry.frontmatter.updated ?? entry.frontmatter.date);
if (lastmod) map.set(`/writings/${entry.slug}/`, lastmod);
}
for (const entry of readEntries(path.join(contentDir, "bookshelf"))) {
const lastmod = isoOrNull(entry.frontmatter.date_finished ?? entry.frontmatter.date_started);
if (lastmod) map.set(`/bookshelf/${entry.slug}/`, lastmod);
}
return map;
}
export function resolveContentDir(configFileUrl) {
return path.join(path.dirname(fileURLToPath(configFileUrl)), "..", "..", "content");
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Lighthouse CI — runs the same audits PageSpeed Insights reports on
* (performance, accessibility, best-practices, SEO) against the built site,
* so results can be tracked over time / per-commit without depending on the
* hosted PSI API. `staticDistDir` lets lhci serve `dist/` itself, no dev
* server juggling needed. Reporting only for now — no `assert` thresholds,
* so a regression shows up in the report without failing the build.
*/
module.exports = {
ci: {
collect: {
staticDistDir: "./dist",
url: [
"/index.html",
"/writings/index.html",
"/writings/from-c-to-machine-code/index.html",
"/archive/index.html",
"/portfolio/index.html",
"/bookshelf/index.html",
"/tags/index.html",
"/about/index.html",
],
numberOfRuns: 3,
},
upload: {
// Free, no account/server needed — each run prints a shareable report
// link (expires after 7 days). Point this at a real LHCI server later
// if longer-lived historical tracking is wanted.
target: "temporary-public-storage",
},
},
};
+15
View File
@@ -0,0 +1,15 @@
// Desktop form-factor pass — same pages/config as lighthouserc.cjs, but
// Lighthouse's default (and PageSpeed Insights' default) is mobile: throttled
// CPU/network and a narrow viewport. Auditing desktop too means neither form
// factor's numbers are a blind spot.
const mobileConfig = require("./lighthouserc.cjs");
module.exports = {
ci: {
...mobileConfig.ci,
collect: {
...mobileConfig.ci.collect,
settings: { preset: "desktop" },
},
},
};
+5 -1
View File
@@ -12,7 +12,8 @@
"build": "pnpm run generate:llms && pnpm run generate:og && astro build && pagefind --site dist",
"preview": "astro preview",
"check": "astro check",
"astro": "astro"
"astro": "astro",
"lighthouse": "pnpm run build && lhci autorun && lhci autorun --config=lighthouserc.desktop.cjs"
},
"dependencies": {
"@astrojs/rss": "^4.0.19",
@@ -22,7 +23,9 @@
"@fontsource/jetbrains-mono": "^5.3.0",
"astro": "^7.2.9",
"highlight.js": "^11.12.0",
"katex": "^0.18.7",
"marked": "^18.0.11",
"marked-katex-extension": "^5.1.12",
"pagefind": "^1.5.2",
"smol-toml": "^1.8.0",
"three": "^0.185.1"
@@ -32,6 +35,7 @@
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@lhci/cli": "^0.15.1",
"@types/three": "^0.185.4",
"typescript": "^5.9.3"
}
-1
View File
@@ -26,7 +26,6 @@
- [The One Thing Nobody Tells You About WiFi Hotspots on Arch Linux](https://andwati.com/writings/wifi-hotspot-arch/): A minimal Arch install is great until NetworkManager silently fails to find a binary you didn't know you needed
- [Setting up Obsidian sync in Windows](https://andwati.com/writings/obsidian-sync/): Syncing Obsidian in Windows with Windows Task Scheduler with git
- [The Single Byte That Kills Your Exploit: Understanding Endianness](https://andwati.com/writings/the-single-byte-that-kills-your-exploit-understanding-endianness/): Problem: Computers speak different languages, like people
- [Why This Site Exists](https://andwati.com/writings/example-first-post/): A short note on what this new site is, and why it's more than a tech blog.
- [mmuctf 2025 writeup](https://andwati.com/writings/mmuctf2025/): mmuctf 2025 challenges writeup
- [Configure Vue.js app With Tailwind CSS](https://andwati.com/writings/vuejs-tailwind/): Tailwindcss in vuejs
- [Subresource Integrity](https://andwati.com/writings/subresource-integrity/): Understanding Subresource Integrity
+41
View File
@@ -1,4 +1,45 @@
User-agent: *
Allow: /
# AI assistants / agents / LLM training crawlers — explicitly welcome.
# See /llms.txt for a crawler-friendly summary of this site's content.
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Perplexity-User
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: CCBot
Allow: /
User-agent: Bytespider
Allow: /
User-agent: Amazonbot
Allow: /
Sitemap: https://andwati.com/sitemap-index.xml
+21
View File
@@ -0,0 +1,21 @@
---
/**
* Self-hosted Umami analytics. No-ops until PUBLIC_UMAMI_SCRIPT_URL and
* PUBLIC_UMAMI_WEBSITE_ID are set (see .env.example) — fill those in once
* the Umami instance is deployed, no code changes needed.
*/
const scriptUrl = import.meta.env.PUBLIC_UMAMI_SCRIPT_URL;
const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
const domains = import.meta.env.PUBLIC_UMAMI_DOMAINS;
---
{
scriptUrl && websiteId && (
<script
defer
src={scriptUrl}
data-website-id={websiteId}
data-domains={domains || undefined}
/>
)
}
+10 -5
View File
@@ -136,11 +136,16 @@ const { items, activeSlug } = Astro.props;
</style>
<script>
document.querySelectorAll<HTMLElement>("[data-spines]").forEach((root) => {
const track = root.querySelector<HTMLElement>("[data-track]");
const next = root.querySelector<HTMLButtonElement>("[data-next]");
next?.addEventListener("click", () => {
track?.scrollBy({ left: 320, behavior: "smooth" });
// astro:page-load fires on first load and after every client-side
// navigation (bookshelf index <-> detail pages both render this
// component), so the button keeps working once view transitions swap it in.
document.addEventListener("astro:page-load", () => {
document.querySelectorAll<HTMLElement>("[data-spines]").forEach((root) => {
const track = root.querySelector<HTMLElement>("[data-track]");
const next = root.querySelector<HTMLButtonElement>("[data-next]");
next?.addEventListener("click", () => {
track?.scrollBy({ left: 320, behavior: "smooth" });
});
});
});
</script>
+84
View File
@@ -0,0 +1,84 @@
---
const repo = "andwati/andwati.com";
const themeDark = "gruvbox-dark";
const themeLight = "github-light";
---
<div
class="comments"
id="comments"
data-utterances-theme-dark={themeDark}
data-utterances-theme-light={themeLight}
>
<script
src="https://utteranc.es/client.js"
repo={repo}
issue-term="pathname"
theme={themeDark}
crossorigin="anonymous"
async
></script>
</div>
<style>
.comments {
margin-top: var(--space-8);
padding-top: var(--space-6);
border-top: 1px solid var(--border);
}
</style>
<script>
// Utterances only reads its `theme` attribute once at load, so later
// theme-toggle clicks must be relayed through postMessage instead.
function isDark() {
const explicit = document.documentElement.getAttribute("data-theme");
if (explicit === "dark" || explicit === "light") return explicit === "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function syncUtterancesTheme() {
const commentsEl = document.getElementById("comments");
const frame = document.querySelector<HTMLIFrameElement>(".utterances-frame");
if (!commentsEl || !frame || !frame.contentWindow) return;
const theme = isDark()
? commentsEl.dataset.utterancesThemeDark
: commentsEl.dataset.utterancesThemeLight;
try {
frame.contentWindow.postMessage({ type: "set-theme", theme }, "https://utteranc.es");
} catch {
// The iframe briefly shares our origin before navigating to
// utteranc.es, so posting can throw until that navigation finishes.
}
}
// Each navigation to a new article gets a fresh observer/poller — without
// disconnecting the previous page's, they'd accumulate across client-side
// navigations since document.documentElement persists across view
// transitions.
let observer: MutationObserver | undefined;
let poll: ReturnType<typeof setInterval> | undefined;
function initComments() {
observer?.disconnect();
if (poll) clearInterval(poll);
if (!document.getElementById("comments")) return;
observer = new MutationObserver(syncUtterancesTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
let tries = 0;
poll = setInterval(() => {
if (document.querySelector(".utterances-frame") || ++tries > 20) {
clearInterval(poll);
syncUtterancesTheme();
}
}, 250);
}
document.addEventListener("astro:page-load", initComments);
</script>
+39 -12
View File
@@ -52,19 +52,46 @@ const graph = await buildConstellationGraph();
<script>
import type { ConstellationGraph } from "../lib/constellation-data";
import { mountConstellation, supportsWebGL } from "../lib/constellation-scene";
import { supportsWebGL } from "../lib/webgl-support";
const root = document.querySelector<HTMLElement>("[data-constellation]");
const canvas = root?.querySelector<HTMLElement>("[data-canvas]");
const tooltip = root?.querySelector<HTMLElement>("[data-tooltip]");
const dataEl = document.getElementById("constellation-data");
// A plain top-level mount only ever runs once per module URL, so
// navigating away from "/" and back via a view transition would leave the
// canvas blank. astro:page-load fires on first load and every subsequent
// client-side navigation to "/", and the stored dispose() tears down the
// previous mount's WebGL context/listeners/rAF loop first.
let dispose: (() => void) | undefined;
if (root && canvas && tooltip && dataEl?.textContent && supportsWebGL()) {
const graph: ConstellationGraph = JSON.parse(dataEl.textContent);
if (graph.nodes.length > 0) {
mountConstellation(canvas, tooltip, graph);
document.addEventListener("astro:page-load", () => {
dispose?.();
dispose = undefined;
const root = document.querySelector<HTMLElement>("[data-constellation]");
const canvas = root?.querySelector<HTMLElement>("[data-canvas]");
const tooltip = root?.querySelector<HTMLElement>("[data-tooltip]");
const dataEl = document.getElementById("constellation-data");
if (!root) return;
if (!(canvas && tooltip && dataEl?.textContent && supportsWebGL())) {
root.style.display = "none";
return;
}
} else if (root) {
root.style.display = "none";
}
const graph: ConstellationGraph = JSON.parse(dataEl.textContent);
if (graph.nodes.length === 0) {
root.style.display = "none";
return;
}
// three.js (~130KB gzip) is dynamically imported here instead of at the
// top of this module, and only once the browser is idle — this is
// decorative, so it must never compete with first paint / LCP for main-
// thread time. Falls back to a short timeout on browsers without
// requestIdleCallback (Safari).
const schedule =
window.requestIdleCallback ?? ((cb: () => void) => window.setTimeout(cb, 200));
schedule(async () => {
const { mountConstellation } = await import("../lib/constellation-scene");
dispose = mountConstellation(canvas, tooltip, graph);
root.style.display = "";
});
});
</script>
+160
View File
@@ -0,0 +1,160 @@
---
---
<div class="minimap" aria-hidden="true">
<div class="minimap-content"></div>
<div class="minimap-viewport"></div>
</div>
<style>
.minimap {
display: none;
}
@media (min-width: 90rem) {
.minimap {
display: block;
position: fixed;
top: var(--space-6);
bottom: var(--space-6);
right: var(--space-5);
width: 9rem;
z-index: 10;
cursor: grab;
overflow: hidden;
}
.minimap:active {
cursor: grabbing;
}
.minimap-content {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
pointer-events: none;
user-select: none;
opacity: 0.6;
filter: saturate(0.65);
}
.minimap-content :is(h1, h2, h3) {
text-decoration-thickness: 0.18em;
}
.minimap-viewport {
position: absolute;
left: -0.4rem;
right: -0.4rem;
border: 1px solid var(--accent);
background: var(--accent-muted);
border-radius: var(--radius-sm);
pointer-events: none;
}
}
</style>
<script>
function initMinimap(signal: AbortSignal) {
const minimap = document.querySelector(".minimap");
const source = document.querySelector(".article-main");
if (!(minimap instanceof HTMLElement) || !(source instanceof HTMLElement)) {
return;
}
const content = minimap.querySelector(".minimap-content");
const viewport = minimap.querySelector(".minimap-viewport");
if (!(content instanceof HTMLElement) || !(viewport instanceof HTMLElement)) {
return;
}
const clone = source.cloneNode(true) as HTMLElement;
clone
.querySelectorAll("script, [id]")
.forEach((el) => el.removeAttribute("id"));
clone.querySelectorAll("script").forEach((el) => el.remove());
clone
.querySelectorAll("a, button, input, select, textarea")
.forEach((el) => el.setAttribute("tabindex", "-1"));
content.replaceChildren(clone);
content.style.width = `${source.offsetWidth}px`;
let scale = 1;
let sourceTop = 0;
function layout() {
sourceTop = source.getBoundingClientRect().top + window.scrollY;
const sourceHeight = source.scrollHeight;
const mapHeight = minimap.clientHeight;
scale = Math.min(mapHeight / sourceHeight, 1);
content.style.transform = `scale(${scale})`;
update();
}
function update() {
const maxTop = minimap.clientHeight - viewport.offsetHeight;
const top = (window.scrollY - sourceTop) * scale;
viewport.style.height = `${window.innerHeight * scale}px`;
viewport.style.top = `${Math.max(0, Math.min(top, Math.max(maxTop, 0)))}px`;
}
function seek(clientY: number) {
const rect = minimap.getBoundingClientRect();
const y = clientY - rect.top;
const target = sourceTop + y / scale - window.innerHeight / 2;
window.scrollTo({ top: target, behavior: "auto" });
}
let dragging = false;
minimap.addEventListener(
"pointerdown",
(e) => {
dragging = true;
minimap.setPointerCapture(e.pointerId);
seek(e.clientY);
},
{ signal },
);
minimap.addEventListener(
"pointermove",
(e) => {
if (dragging) seek(e.clientY);
},
{ signal },
);
minimap.addEventListener("pointerup", () => (dragging = false), { signal });
let ticking = false;
window.addEventListener(
"scroll",
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
update();
ticking = false;
});
},
{ signal },
);
window.addEventListener("resize", layout, { signal });
layout();
}
// Each navigation gets a fresh controller so the previous page's
// window-level scroll/resize listeners are torn down before the new
// page's initMinimap() adds its own — otherwise they'd accumulate across
// client-side navigations between article pages.
let controller: AbortController | undefined;
document.addEventListener("astro:page-load", () => {
controller?.abort();
controller = new AbortController();
initMinimap(controller.signal);
});
</script>
@@ -0,0 +1,63 @@
---
---
<div class="reading-progress-container" aria-hidden="true">
<div class="reading-progress-bar" id="reading-progress-bar"></div>
</div>
<style>
.reading-progress-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
z-index: 1000;
pointer-events: none;
}
.reading-progress-bar {
height: 100%;
width: 0%;
background: var(--accent);
transition: width 0.1s var(--ease-out);
}
</style>
<script>
function initReadingProgress(signal: AbortSignal) {
const bar = document.getElementById("reading-progress-bar");
if (!(bar instanceof HTMLElement)) return;
const update = () => {
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
const scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight;
const clientHeight = document.documentElement.clientHeight || window.innerHeight;
const total = scrollHeight - clientHeight;
bar.style.width = `${total > 0 ? (scrollTop / total) * 100 : 0}%`;
};
update();
let ticking = false;
window.addEventListener(
"scroll",
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
update();
ticking = false;
});
},
{ signal },
);
}
let controller: AbortController | undefined;
document.addEventListener("astro:page-load", () => {
controller?.abort();
controller = new AbortController();
initReadingProgress(controller.signal);
});
</script>
+105
View File
@@ -0,0 +1,105 @@
---
---
<button id="scroll-to-top" class="scroll-to-top" aria-label="Scroll to top" type="button">
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
<style>
.scroll-to-top {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--accent);
border: 1px solid var(--accent);
cursor: pointer;
z-index: 100;
opacity: 0;
visibility: hidden;
transform: translateY(0.75rem);
transition:
transform 0.25s var(--ease-out),
opacity 0.25s var(--ease-out),
visibility 0.25s var(--ease-out),
background-color 0.2s var(--ease-out),
color 0.2s var(--ease-out);
}
.scroll-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
@media (hover: hover) {
.scroll-to-top:hover {
background: var(--accent);
color: var(--bg);
}
}
.scroll-to-top:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (max-width: 40rem) {
.scroll-to-top {
bottom: var(--space-5);
right: var(--space-5);
}
}
</style>
<script>
function initScrollToTop(signal: AbortSignal) {
const btn = document.getElementById("scroll-to-top");
if (!(btn instanceof HTMLElement)) return;
const toggleVisibility = () => {
btn.classList.toggle("visible", window.scrollY > 400);
};
btn.addEventListener(
"click",
() => window.scrollTo({ top: 0, behavior: "smooth" }),
{ signal },
);
let ticking = false;
window.addEventListener(
"scroll",
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
toggleVisibility();
ticking = false;
});
},
{ signal },
);
toggleVisibility();
}
// astro:page-load fires on first load and after every client-side
// navigation. Each firing gets a fresh AbortController so the previous
// page's window-level scroll listener is torn down first, instead of
// accumulating across navigations.
let controller: AbortController | undefined;
document.addEventListener("astro:page-load", () => {
controller?.abort();
controller = new AbortController();
initScrollToTop(controller.signal);
});
</script>
+193
View File
@@ -0,0 +1,193 @@
---
---
<div id="search-overlay" class="search-overlay" aria-hidden="true">
<div class="search-modal" role="dialog" aria-modal="true" aria-label="Search">
<div id="search-mount"></div>
<p class="search-empty-hint meta">
Search only works against a built site (`pnpm build` runs Pagefind after `astro build`) — it
has no index to query in `astro dev`.
</p>
<div class="search-footer">
<span>Navigate: <kbd>↑</kbd> <kbd>↓</kbd></span>
<span>Select: <kbd>Enter</kbd></span>
<span>Close: <kbd>Esc</kbd></span>
</div>
</div>
</div>
<style>
.search-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--space-7) var(--space-5);
background: color-mix(in srgb, var(--bg) 70%, transparent);
backdrop-filter: blur(4px);
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s var(--ease-out),
visibility 0.2s var(--ease-out);
}
.search-overlay.active {
opacity: 1;
visibility: visible;
}
.search-modal {
width: 100%;
max-width: 38rem;
max-height: calc(100vh - 2 * var(--space-7));
overflow-y: auto;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-5);
transform: translateY(-0.5rem);
transition: transform 0.2s var(--ease-out);
}
.search-overlay.active .search-modal {
transform: translateY(0);
}
.search-empty-hint {
margin: 0;
}
.search-footer {
display: none;
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
justify-content: space-between;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.search-overlay.active .search-footer {
display: flex;
}
.search-footer kbd {
font-family: inherit;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0 0.35em;
}
/* Pagefind UI theming, mapped onto this site's tokens. */
#search-mount {
--pagefind-ui-scale: 0.9;
--pagefind-ui-primary: var(--text);
--pagefind-ui-text: var(--text);
--pagefind-ui-background: transparent;
--pagefind-ui-border: var(--border);
--pagefind-ui-tag: var(--bg);
--pagefind-ui-border-width: 1px;
--pagefind-ui-border-radius: 6px;
--pagefind-ui-font: var(--font-body);
}
</style>
<script>
let pagefindLoaded = false;
let pagefindLoading: Promise<void> | undefined;
function loadPagefind(): Promise<void> {
if (pagefindLoaded) return Promise.resolve();
if (pagefindLoading) return pagefindLoading;
pagefindLoading = new Promise((resolve) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/pagefind/pagefind-ui.css";
document.head.appendChild(link);
const script = document.createElement("script");
script.src = "/pagefind/pagefind-ui.js";
script.onload = () => {
const mount = document.getElementById("search-mount");
const hint = document.querySelector(".search-empty-hint");
// biome-ignore lint/correctness/noUndeclaredVariables: loaded from /pagefind/pagefind-ui.js
if (mount && typeof PagefindUI !== "undefined") {
// biome-ignore lint/correctness/noUndeclaredVariables: loaded from /pagefind/pagefind-ui.js
new PagefindUI({ element: mount, showSubResults: true, showImages: false });
hint?.remove();
}
pagefindLoaded = true;
resolve();
};
script.onerror = () => resolve();
document.body.appendChild(script);
});
return pagefindLoading;
}
function initSearchModal(signal: AbortSignal) {
const overlay = document.getElementById("search-overlay");
const toggle = document.getElementById("search-toggle");
if (!overlay || !toggle) return;
const isOpen = () => overlay.classList.contains("active");
const open = () => {
overlay.classList.add("active");
overlay.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
loadPagefind().then(() => {
overlay.querySelector<HTMLInputElement>(".pagefind-ui__search-input")?.focus();
});
};
const close = () => {
overlay.classList.remove("active");
overlay.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
};
toggle.addEventListener(
"click",
(e) => {
e.preventDefault();
isOpen() ? close() : open();
},
{ signal },
);
overlay.addEventListener(
"click",
(e) => {
if (e.target === overlay) close();
},
{ signal },
);
document.addEventListener(
"keydown",
(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
isOpen() ? close() : open();
return;
}
if (e.key === "Escape" && isOpen()) close();
},
{ signal },
);
}
let controller: AbortController | undefined;
document.addEventListener("astro:page-load", () => {
controller?.abort();
controller = new AbortController();
initSearchModal(controller.signal);
});
</script>
+164
View File
@@ -0,0 +1,164 @@
---
interface Props {
title: string;
url: string;
}
const { title, url } = Astro.props;
const encodedTitle = encodeURIComponent(title);
const encodedUrl = encodeURIComponent(url);
---
<div class="social-share">
<span class="share-label">Share:</span>
<a
href={`https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
class="share-btn x"
aria-label="Share on X"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path
d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"
></path>
</svg>
</a>
<a
href={`https://bsky.app/intent/compose?text=${encodedTitle}%20${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
class="share-btn bluesky"
aria-label="Share on Bluesky"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path
d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"
></path>
</svg>
</a>
<a
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
class="share-btn linkedin"
aria-label="Share on LinkedIn"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18"
height="18"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path>
<rect x="2" y="9" width="4" height="12"></rect>
<circle cx="4" cy="4" r="2"></circle>
</svg>
</a>
<a
href={`https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
class="share-btn reddit"
aria-label="Share on Reddit"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
></path>
</svg>
</a>
<a
href={`https://news.ycombinator.com/submitlink?u=${encodedUrl}&t=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
class="share-btn hackernews"
aria-label="Share on Hacker News"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M0 24V0h24v24H0zM6.95 5.55L11 13v5h2v-5l4.05-7.45h-2.25L12 10.65 9.2 5.55H6.95z"></path>
</svg>
</a>
<a href={`mailto:?subject=${encodedTitle}&body=${encodedUrl}`} class="share-btn email" aria-label="Share via Email">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
</a>
</div>
<style>
.social-share {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
.share-label {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.share-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
border: 1px solid var(--border);
color: var(--text-muted);
transition:
color 0.2s var(--ease-out),
background-color 0.2s var(--ease-out),
border-color 0.2s var(--ease-out),
transform 0.2s var(--ease-out);
}
.share-btn:hover {
color: #fff;
transform: translateY(-2px);
border-color: transparent;
}
.share-btn.x:hover {
background: #000;
}
.share-btn.bluesky:hover {
background: #0285ff;
}
.share-btn.linkedin:hover {
background: #0077b5;
}
.share-btn.reddit:hover {
background: #ff4500;
}
.share-btn.hackernews:hover {
background: #ff6600;
}
.share-btn.email:hover {
background: #6b7280;
}
</style>
+1
View File
@@ -20,6 +20,7 @@ const writings = defineCollection({
canonical_url: z.string().optional(),
wordCount: z.number().optional(),
readingMinutes: z.number().optional(),
youtubeIds: z.array(z.string()).default([]),
taxonomies,
extra: z
.object({
+115 -20
View File
@@ -3,23 +3,64 @@ import "@fontsource-variable/fraunces";
import "@fontsource-variable/newsreader";
import "@fontsource/jetbrains-mono";
import "../styles/global.css";
import { ClientRouter } from "astro:transitions";
import { copyrightYearRange } from "../lib/copyright-year";
import { siteConfig } from "../site.config";
import Analytics from "../components/Analytics.astro";
import ScrollToTop from "../components/ScrollToTop.astro";
import SearchModal from "../components/SearchModal.astro";
interface Props {
title: string;
description?: string;
/** Widens .container for dashboard-style layouts (currently just the homepage). */
/** Narrows .container to the prose measure — opt in for pages with no wide layout needs. */
wide?: boolean;
/** Site-root-relative path to a per-page OG image, e.g. /images/og/writings/foo.png */
ogImage?: string;
/** Overrides the self-referential canonical (e.g. a writing first published elsewhere). */
canonicalUrl?: string;
/** Page type for the og:type meta — "article" for writings, "website" otherwise. */
ogType?: "website" | "article";
/** Extra JSON-LD object(s) beyond the sitewide WebSite/Person graph. */
structuredData?: object | object[];
}
const { title, description, wide = false, ogImage } = Astro.props;
const {
title,
description,
wide = true,
ogImage,
canonicalUrl,
ogType = "website",
structuredData,
} = Astro.props;
const metaDescription = description ?? siteConfig.description;
const currentPath = Astro.url.pathname;
const copyrightRange = await copyrightYearRange();
const pageTitle = `${title} — ${siteConfig.title}`;
const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href;
const ogImageUrl = new URL(ogImage ?? "/favicon.ico", siteConfig.baseUrl).href;
const pageCanonicalUrl = canonicalUrl ?? new URL(currentPath, siteConfig.baseUrl).href;
const siteJsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": `${siteConfig.baseUrl}/#website`,
url: siteConfig.baseUrl,
name: siteConfig.title,
description: siteConfig.description,
author: { "@id": `${siteConfig.baseUrl}/#person` },
},
{
"@type": "Person",
"@id": `${siteConfig.baseUrl}/#person`,
name: siteConfig.author,
url: siteConfig.baseUrl,
sameAs: [siteConfig.social.github, siteConfig.social.twitter, siteConfig.social.youtube],
},
],
};
const extraJsonLd = structuredData ? (Array.isArray(structuredData) ? structuredData : [structuredData]) : [];
---
<html lang="en">
@@ -28,10 +69,13 @@ const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href;
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{pageTitle}</title>
<meta name="description" content={metaDescription} />
<link rel="canonical" href={pageCanonicalUrl} />
<link rel="alternate" type="application/rss+xml" title={siteConfig.title} href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title={siteConfig.title} href="/atom.xml" />
<link rel="alternate" type="application/feed+json" title={siteConfig.title} href="/feed.json" />
<link rel="webmention" href="https://webmention.io/andwati.com/webmention" />
<meta property="og:type" content="website" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={siteConfig.title} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={metaDescription} />
<meta property="og:url" content={Astro.url.href} />
@@ -40,8 +84,18 @@ const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href;
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={metaDescription} />
<meta name="twitter:image" content={ogImageUrl} />
<script is:inline>
// Runs before paint to avoid a flash of the wrong theme.
<script type="application/ld+json" set:html={JSON.stringify(siteJsonLd)} />
{
extraJsonLd.map((data) => (
<script type="application/ld+json" set:html={JSON.stringify(data)} />
))
}
<Analytics />
<ClientRouter />
<script is:inline data-astro-rerun>
// Runs before paint to avoid a flash of the wrong theme. Also reruns
// after every view-transition navigation, since Astro's head swap can
// otherwise clobber the data-theme attribute this sets on <html>.
(function () {
try {
var stored = localStorage.getItem("theme");
@@ -86,27 +140,46 @@ const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href;
nav a[aria-current="page"] {
color: var(--accent);
}
#theme-toggle {
.icon-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border: none;
color: var(--text-muted);
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
font-family: var(--font-mono);
font-size: 0.9rem;
transition:
border-color 0.2s var(--ease-out),
color 0.2s var(--ease-out);
transition: color 0.2s var(--ease-out);
}
#theme-toggle:hover {
border-color: var(--accent);
.icon-btn:hover {
color: var(--accent);
}
.icon-btn svg {
width: 18px;
height: 18px;
}
.moon-icon {
display: block;
}
.sun-icon {
display: none;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .sun-icon {
display: block;
}
:root:not([data-theme="light"]) .moon-icon {
display: none;
}
}
:root[data-theme="dark"] .sun-icon {
display: block;
}
:root[data-theme="dark"] .moon-icon {
display: none;
}
main {
min-height: 60vh;
}
@@ -138,8 +211,28 @@ const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href;
))
}
<a href="/tags/" aria-current={currentPath.startsWith("/tags/") ? "page" : undefined}>Tags</a>
<a href="/search/" aria-current={currentPath.startsWith("/search/") ? "page" : undefined}>Search</a>
<button id="theme-toggle" type="button" aria-label="Toggle color theme">◐</button>
<button id="search-toggle" class="icon-btn" type="button" aria-label="Search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button id="theme-toggle" class="icon-btn" type="button" aria-label="Toggle color theme">
<svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
</nav>
</header>
<main>
@@ -155,7 +248,9 @@ const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href;
</nav>
</footer>
</div>
<script is:inline>
<ScrollToTop />
<SearchModal />
<script is:inline data-astro-rerun>
document.getElementById("theme-toggle").addEventListener("click", function () {
var root = document.documentElement;
var current =
+39 -35
View File
@@ -34,18 +34,6 @@ function fibonacciSphere(count: number, radius: number): THREE.Vector3[] {
return points;
}
export function supportsWebGL(): boolean {
try {
const canvas = document.createElement("canvas");
return !!(
window.WebGLRenderingContext &&
(canvas.getContext("webgl") || canvas.getContext("experimental-webgl"))
);
} catch {
return false;
}
}
export function mountConstellation(
container: HTMLElement,
tooltip: HTMLElement,
@@ -108,11 +96,13 @@ export function mountConstellation(
raycaster.params.Points = { threshold: 0.3 };
const pointer = new THREE.Vector2(-2, -2);
let hovered: THREE.Mesh | null = null;
let pointerMoved = false;
function onPointerMove(event: PointerEvent) {
const rect = container.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointerMoved = true;
}
function onClick() {
@@ -150,32 +140,46 @@ export function mountConstellation(
});
}
// A slow constant rotation plus hover-raycasting doesn't need 60fps, and
// raycasting every frame regardless of pointer movement was pure waste —
// capping to ~30fps and only raycasting on an actual pointer move cut this
// scene from being the site's single largest main-thread cost (it was
// tanking the homepage's Lighthouse performance score to the low 80s while
// every other page scored 95+) without changing how it looks or behaves.
const frameInterval = 1000 / 30;
let lastFrameTime = 0;
let frame = 0;
function animate() {
function animate(time: number) {
frame = requestAnimationFrame(animate);
if (!reduceMotion) group.rotation.y += 0.0009;
if (time - lastFrameTime < frameInterval) return;
lastFrameTime = time;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(nodeMeshes);
const hit = hits[0]?.object as THREE.Mesh | undefined;
if (!reduceMotion) group.rotation.y += 0.0018;
if (hit !== hovered) {
if (hovered)
(hovered.material as THREE.MeshBasicMaterial).color.copy(
cssColor(TYPE_COLOR_VAR[hovered.userData.node.type]),
);
hovered = hit ?? null;
if (hovered) {
(hovered.material as THREE.MeshBasicMaterial).color.copy(
cssColor("--accent"),
);
const node = hovered.userData.node as { title: string };
tooltip.textContent = node.title;
tooltip.style.opacity = "1";
container.style.cursor = "pointer";
} else {
tooltip.style.opacity = "0";
container.style.cursor = "default";
if (pointerMoved) {
pointerMoved = false;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(nodeMeshes);
const hit = hits[0]?.object as THREE.Mesh | undefined;
if (hit !== hovered) {
if (hovered)
(hovered.material as THREE.MeshBasicMaterial).color.copy(
cssColor(TYPE_COLOR_VAR[hovered.userData.node.type]),
);
hovered = hit ?? null;
if (hovered) {
(hovered.material as THREE.MeshBasicMaterial).color.copy(
cssColor("--accent"),
);
const node = hovered.userData.node as { title: string };
tooltip.textContent = node.title;
tooltip.style.opacity = "1";
container.style.cursor = "pointer";
} else {
tooltip.style.opacity = "0";
container.style.cursor = "default";
}
}
}
if (hovered) {
@@ -187,7 +191,7 @@ export function mountConstellation(
renderer.render(scene, camera);
}
animate();
animate(0);
return function destroy() {
cancelAnimationFrame(frame);
+52 -2
View File
@@ -8,6 +8,34 @@ function hashString(value: string): number {
return Math.abs(hash);
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function relativeLuminance([r, g, b]: [number, number, number]): number {
const lin = (c: number) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrastRatio(rgb1: [number, number, number], rgb2: [number, number, number]): number {
const l1 = relativeLuminance(rgb1);
const l2 = relativeLuminance(rgb2);
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
return (lighter + 0.05) / (darker + 0.05);
}
const DARK_TEXT: [number, number, number] = [0x21, 0x1d, 0x16];
const LIGHT_TEXT: [number, number, number] = [0xf2, 0xed, 0xe1];
const MIN_CONTRAST = 4.5;
export interface SpineStyle {
background: string;
color: string;
@@ -18,11 +46,33 @@ export function spineStyle(title: string): SpineStyle {
const hash = hashString(title);
const hue = hash % 360;
const saturation = 30 + (hash % 25);
const lightness = 28 + ((hash >> 4) % 30);
let lightness = 28 + ((hash >> 4) % 30);
// HSL lightness doesn't track perceptual luminance closely enough for
// WCAG contrast math — especially for high-luminance hues like green/
// yellow, a "dark" 43% lightness can still read as too bright for light
// text. Compute real contrast and push lightness toward whichever
// extreme the winning text color needs until it clears 4.5:1, instead of
// trusting a fixed lightness threshold.
let rgb = hslToRgb(hue, saturation, lightness);
let darkContrast = contrastRatio(rgb, DARK_TEXT);
let lightContrast = contrastRatio(rgb, LIGHT_TEXT);
for (let i = 0; i < 20 && Math.max(darkContrast, lightContrast) < MIN_CONTRAST; i++) {
if (darkContrast > lightContrast) {
lightness = Math.min(96, lightness + 3);
} else {
lightness = Math.max(4, lightness - 3);
}
rgb = hslToRgb(hue, saturation, lightness);
darkContrast = contrastRatio(rgb, DARK_TEXT);
lightContrast = contrastRatio(rgb, LIGHT_TEXT);
}
const width = 42 + (hash % 6) * 7;
return {
background: `hsl(${hue} ${saturation}% ${lightness}%)`,
color: lightness > 52 ? "#211d16" : "#f2ede1",
color: darkContrast >= lightContrast ? "#211d16" : "#f2ede1",
width,
};
}
+46 -14
View File
@@ -14,6 +14,7 @@ import hljsX86asm from "highlight.js/lib/languages/x86asm";
import hljsXml from "highlight.js/lib/languages/xml";
import hljsYaml from "highlight.js/lib/languages/yaml";
import { Marked } from "marked";
import markedKatex from "marked-katex-extension";
import { parse as parseToml } from "smol-toml";
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
@@ -91,20 +92,26 @@ function highlightCode(code: string, lang: string | undefined): string {
return `<pre class="hljs"><code>${inner}</code></pre>`;
}
const marked = new Marked({
renderer: {
heading(token) {
const plainText = stripInlineMarkdown(token.text);
const slug = slugify(plainText);
const html = this.parser.parseInline(token.tokens);
currentHeadings.push({ depth: token.depth, slug, text: plainText });
return `<h${token.depth} id="${slug}">${html}</h${token.depth}>`;
},
code(token) {
return highlightCode(token.text, token.lang);
// Renders $inline$ and $$block$$ LaTeX to static HTML/MathML at build time
// (katex.renderToString under the hood) — no client-side JS shipped, unlike
// the legacy Zola site's KaTeX auto-render script.
const marked = new Marked(
markedKatex({ throwOnError: false }),
{
renderer: {
heading(token) {
const plainText = stripInlineMarkdown(token.text);
const slug = slugify(plainText);
const html = this.parser.parseInline(token.tokens);
currentHeadings.push({ depth: token.depth, slug, text: plainText });
return `<h${token.depth} id="${slug}">${html}</h${token.depth}>`;
},
code(token) {
return highlightCode(token.text, token.lang);
},
},
},
});
);
const CALLOUT_ICONS: Record<string, string> = {
note: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>',
@@ -154,6 +161,30 @@ async function renderCallouts(body: string): Promise<string> {
return out;
}
// Matches a plain <iframe> pointing at a youtube-nocookie embed, however an
// author pasted it (any attribute order/extras) — the legacy Zola site had a
// `{{ youtube(id=...) }}` shortcode that wrapped the embed in a responsive
// container, added `loading="lazy"`, and emitted VideoObject JSON-LD;
// migrating posts flattened those calls to bare <iframe> tags (see
// migrate-posts-to-writings.mjs), losing all three. Detecting the plain
// <iframe> here restores them for any post, old or new, with no shortcode
// syntax required.
const YOUTUBE_IFRAME_RE =
/<iframe\b[^>]*\bsrc="https:\/\/www\.youtube-nocookie\.com\/embed\/([\w-]+)[^"]*"[^>]*>\s*<\/iframe>/g;
/** Wraps youtube-nocookie <iframe> embeds in a responsive container and
* adds `loading="lazy"` when missing, returning the transformed HTML plus
* the extracted video ids (for VideoObject JSON-LD). */
function enhanceYoutubeEmbeds(html: string): { html: string; youtubeIds: string[] } {
const youtubeIds: string[] = [];
const out = html.replace(YOUTUBE_IFRAME_RE, (tag, id) => {
youtubeIds.push(id);
const withLazy = /\bloading=/.test(tag) ? tag : tag.replace("<iframe", '<iframe loading="lazy"');
return `<div class="youtube-wrapper">${withLazy}</div>`;
});
return { html: out, youtubeIds };
}
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -205,12 +236,13 @@ export function tomlContentLoader(contentDir: string): Loader {
const id = relPath.replace(/\/index\.md$/, "").replace(/\.md$/, "");
currentHeadings = [];
const trimmedBody = await renderCallouts(body.trim());
const html = await marked.parse(trimmedBody);
const parsedHtml = await marked.parse(trimmedBody);
const { html, youtubeIds } = enhanceYoutubeEmbeds(parsedHtml);
const wordCount = trimmedBody.split(/\s+/).filter(Boolean).length;
const readingMinutes = Math.max(1, Math.round(wordCount / 200));
store.set({
id,
data: { ...data, wordCount, readingMinutes },
data: { ...data, wordCount, readingMinutes, youtubeIds },
rendered: { html, metadata: { headings: currentHeadings } },
});
}
+14
View File
@@ -0,0 +1,14 @@
// Split out from constellation-scene.ts so checking WebGL support doesn't
// require fetching/evaluating the three.js bundle it lives alongside —
// Constellation.astro checks this before dynamically importing that module.
export function supportsWebGL(): boolean {
try {
const canvas = document.createElement("canvas");
return !!(
window.WebGLRenderingContext &&
(canvas.getContext("webgl") || canvas.getContext("experimental-webgl"))
);
} catch {
return false;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
import { siteConfig } from "../site.config";
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export async function GET(context: APIContext) {
const site = (context.site ?? new URL(siteConfig.baseUrl)).href.replace(/\/$/, "");
const writings = (
await getCollection("writings", ({ data }) => !data.draft)
).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const updated = writings[0]?.data.updated ?? writings[0]?.data.date ?? new Date();
const entries = writings
.map((entry) => {
const url = `${site}/writings/${entry.id}/`;
const published = entry.data.date.toISOString();
const entryUpdated = (entry.data.updated ?? entry.data.date).toISOString();
const categories = (entry.data.taxonomies?.tags ?? [])
.map((tag) => `<category term="${escapeXml(tag)}" />`)
.join("");
return `
<entry>
<title>${escapeXml(entry.data.title)}</title>
<link href="${url}" />
<id>${url}</id>
<published>${published}</published>
<updated>${entryUpdated}</updated>
${entry.data.description ? `<summary>${escapeXml(entry.data.description)}</summary>` : ""}
<author><name>${escapeXml(entry.data.author ?? siteConfig.author)}</name></author>
${categories}
</entry>`;
})
.join("");
const xml = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${escapeXml(siteConfig.title)}</title>
<subtitle>${escapeXml(siteConfig.description)}</subtitle>
<link href="${site}/atom.xml" rel="self" />
<link href="${site}/" />
<id>${site}/</id>
<updated>${updated.toISOString()}</updated>
${entries}
</feed>`;
return new Response(xml, {
headers: { "Content-Type": "application/atom+xml; charset=utf-8" },
});
}
+3 -2
View File
@@ -1,4 +1,5 @@
---
import "katex/dist/katex.min.css";
import { getCollection, render } from "astro:content";
import Base from "../../layouts/Base.astro";
@@ -16,9 +17,9 @@ const rendered = await Promise.all(
{
rendered.map(({ entry, Content }) => (
<div class="card">
<h3>
<h2 class="entry-title">
<a href={entry.data.url}>{entry.data.title}</a>
</h3>
</h2>
<Content />
</div>
))
@@ -1,4 +1,5 @@
---
import "katex/dist/katex.min.css";
import { getCollection, render } from "astro:content";
import BookSpines from "../../components/BookSpines.astro";
import Base from "../../layouts/Base.astro";
+3 -2
View File
@@ -1,4 +1,5 @@
---
import "katex/dist/katex.min.css";
import { getCollection, render } from "astro:content";
import BookSpines from "../../components/BookSpines.astro";
import Base from "../../layouts/Base.astro";
@@ -37,9 +38,9 @@ const withBodies = await Promise.all(
)}
</a>
<div>
<h3>
<h2 class="entry-title">
<a href={`/bookshelf/${entry.id}/`}>{entry.data.title}</a> <span class="tag">{entry.data.kind}</span>
</h3>
</h2>
<p class="meta">
{entry.data.authors.join(", ")}
{entry.data.date_finished && <> · Read {entry.data.date_finished.toDateString()}</>}
+3 -3
View File
@@ -21,7 +21,7 @@ const bookshelf = (await getCollection("bookshelf")).slice(0, 3);
<div class="columns">
<section>
<p class="eyebrow">Writings</p>
<h2 class="eyebrow">Writings</h2>
{
writings.length === 0 ? (
<p class="meta">Nothing published yet.</p>
@@ -40,7 +40,7 @@ const bookshelf = (await getCollection("bookshelf")).slice(0, 3);
</section>
<section>
<p class="eyebrow">Portfolio</p>
<h2 class="eyebrow">Portfolio</h2>
{
portfolio.length === 0 ? (
<p class="meta">Nothing published yet.</p>
@@ -57,7 +57,7 @@ const bookshelf = (await getCollection("bookshelf")).slice(0, 3);
</section>
<section>
<p class="eyebrow">Bookshelf</p>
<h2 class="eyebrow">Bookshelf</h2>
{
bookshelf.length === 0 ? (
<p class="meta">Nothing on the shelf yet.</p>
+1 -1
View File
@@ -15,7 +15,7 @@ const projects = (
{
projects.map((entry) => (
<div class="card">
<h3>{entry.data.title}</h3>
<h2 class="entry-title">{entry.data.title}</h2>
<p class="meta">{entry.data.role}</p>
{entry.data.description && <p>{entry.data.description}</p>}
{entry.data.outcome && <p>{entry.data.outcome}</p>}
+2 -2
View File
@@ -25,9 +25,9 @@ const description = siteConfig.tagDescriptions[tag];
{
writings.map((entry) => (
<div class="card">
<h3>
<h2 class="entry-title">
<a href={`/writings/${entry.id}/`}>{entry.data.title}</a>
</h3>
</h2>
<p class="meta">{entry.data.date.toDateString()}</p>
</div>
))
+52 -11
View File
@@ -9,18 +9,59 @@ for (const w of writings) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
const tags = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]);
const tags = [...tagCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
---
<Base title="All Tags">
<h1>All Tags</h1>
<ul>
{
tags.map(([tag, count]) => (
<li>
<a href={`/tags/${tag}/`}>{tag}</a> ({count})
</li>
))
}
</ul>
<div class="tags-page">
<h1>All Tags</h1>
<div class="tags-cloud">
{
tags.map(([tag, count]) => (
<a href={`/tags/${tag}/`} class="tag-link">
{tag}
<sup class="tag-count">{count}</sup>
</a>
))
}
</div>
</div>
</Base>
<style>
.tags-page {
text-align: center;
}
.tags-page h1 {
display: block;
}
.tags-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
justify-content: center;
align-items: flex-start;
margin-top: var(--space-6);
}
.tag-link {
font-size: var(--font-size-lg);
color: var(--text);
text-decoration: none;
display: inline-flex;
align-items: flex-start;
transition: color 0.2s var(--ease-out);
}
.tag-link:hover {
color: var(--accent);
}
.tag-count {
font-size: 0.65em;
color: var(--text-muted);
margin-left: 0.2rem;
}
</style>
+76 -30
View File
@@ -1,7 +1,13 @@
---
import "katex/dist/katex.min.css";
import { getCollection, render } from "astro:content";
import TocList from "../../components/TocList.astro";
import Minimap from "../../components/Minimap.astro";
import ReadingProgress from "../../components/ReadingProgress.astro";
import ShareButtons from "../../components/ShareButtons.astro";
import Comments from "../../components/Comments.astro";
import Base from "../../layouts/Base.astro";
import { siteConfig } from "../../site.config";
export async function getStaticPaths() {
const writings = await getCollection("writings", ({ data }) => !data.draft);
@@ -33,26 +39,61 @@ const seriesEntries = series
: [];
const tags = entry.data.taxonomies?.tags ?? [];
const related = tags.length
? allWritings
.filter((w) => w.id !== entry.id)
.map((w) => ({
entry: w,
overlap: (w.data.taxonomies?.tags ?? []).filter((t) => tags.includes(t))
.length,
}))
.filter((w) => w.overlap > 0)
.sort((a, b) => b.overlap - a.overlap)
.slice(0, 3)
.map((w) => w.entry)
: [];
const shareUrl = new URL(Astro.url.pathname, siteConfig.baseUrl).href;
const canonicalUrl = entry.data.canonical_url ?? shareUrl;
const ogImageUrl = new URL(`/images/og/writings/${entry.id}.png`, siteConfig.baseUrl).href;
const description =
entry.data.extra?.llms_description ?? entry.data.description;
const tocHeadings = headings.filter((h) => h.depth <= 3);
const articleJsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: entry.data.title,
description,
url: canonicalUrl,
image: ogImageUrl,
datePublished: entry.data.date.toISOString(),
dateModified: (entry.data.updated ?? entry.data.date).toISOString(),
keywords: tags.length ? tags.join(", ") : undefined,
author: { "@id": `${siteConfig.baseUrl}/#person` },
publisher: { "@id": `${siteConfig.baseUrl}/#person` },
mainEntityOfPage: canonicalUrl,
};
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: siteConfig.baseUrl },
{ "@type": "ListItem", position: 2, name: "Writings", item: `${siteConfig.baseUrl}/writings/` },
{ "@type": "ListItem", position: 3, name: entry.data.title, item: canonicalUrl },
],
};
const videoJsonLd = entry.data.youtubeIds.map((id) => ({
"@context": "https://schema.org",
"@type": "VideoObject",
name: entry.data.title,
description,
thumbnailUrl: `https://i.ytimg.com/vi/${id}/sddefault.jpg`,
uploadDate: entry.data.date.toISOString(),
embedUrl: `https://www.youtube-nocookie.com/embed/${id}`,
contentUrl: `https://www.youtube.com/watch?v=${id}`,
}));
---
<Base title={entry.data.title} description={description} wide ogImage={`/images/og/writings/${entry.id}.png`}>
<Base
title={entry.data.title}
description={description}
wide
ogImage={`/images/og/writings/${entry.id}.png`}
ogType="article"
canonicalUrl={canonicalUrl}
structuredData={[articleJsonLd, breadcrumbJsonLd, ...videoJsonLd]}
>
<ReadingProgress />
<div class="article-layout">
<div class="article-main">
<h1>{entry.data.title}</h1>
@@ -92,25 +133,28 @@ const tocHeadings = headings.filter((h) => h.depth <= 3);
<Content />
</article>
<nav class="post-nav" aria-label="Post navigation">
{prev && <a href={`/writings/${prev.id}/`}>← {prev.data.title}</a>}
{next && <a href={`/writings/${next.id}/`}>{next.data.title} →</a>}
</nav>
<ShareButtons title={entry.data.title} url={shareUrl} />
{
related.length > 0 && (
<section>
<h2>Related Posts</h2>
{related.map((w) => (
<div class="card">
<h3>
<a href={`/writings/${w.id}/`}>{w.data.title}</a>
</h3>
</div>
))}
</section>
(prev || next) && (
<nav class="post-nav" aria-label="Post navigation">
{prev && (
<a class="post-nav-link post-nav-prev" href={`/writings/${prev.id}/`}>
<span class="post-nav-label">← Previous</span>
<span class="post-nav-title">{prev.data.title}</span>
</a>
)}
{next && (
<a class="post-nav-link post-nav-next" href={`/writings/${next.id}/`}>
<span class="post-nav-label">Next →</span>
<span class="post-nav-title">{next.data.title}</span>
</a>
)}
</nav>
)
}
<Comments />
</div>
{
@@ -121,13 +165,15 @@ const tocHeadings = headings.filter((h) => h.depth <= 3);
)
}
</div>
<Minimap />
</Base>
<style>
.article-layout {
display: grid;
grid-template-columns: minmax(0, 42rem);
justify-content: center;
justify-content: start;
align-items: start;
gap: var(--space-7);
}
+1 -1
View File
@@ -12,7 +12,7 @@ const writings = (
{
writings.map((entry) => (
<div class="card">
<h3><a href={`/writings/${entry.id}/`}>{entry.data.title}</a></h3>
<h2 class="entry-title"><a href={`/writings/${entry.id}/`}>{entry.data.title}</a></h2>
<p class="meta">
{entry.data.date.toDateString()}
{entry.data.readingMinutes && <> · {entry.data.readingMinutes} min read</>}
+157 -23
View File
@@ -46,25 +46,16 @@ h3 {
line-height: 1.15;
letter-spacing: -0.01em;
margin: 0 0 var(--space-4);
text-decoration-line: underline;
text-decoration-color: var(--accent);
text-decoration-thickness: 0.1em;
text-underline-offset: 0.08em;
text-decoration-skip-ink: auto;
}
h1 {
font-size: var(--font-size-3xl);
display: inline-block;
position: relative;
}
h1::after {
content: "";
position: absolute;
left: 0.05em;
right: 0.4em;
bottom: 0.08em;
height: 0.09em;
background: var(--accent);
opacity: 0.55;
z-index: -1;
border-radius: 1px;
}
h2 {
@@ -75,6 +66,22 @@ h3 {
font-size: var(--font-size-xl);
}
/* A plain <a> has its own default underline (in its own text color, not
the heading's accent), which paints over the heading's underline and
made link-wrapped titles inconsistent with plain-text ones (and with
each other, depending on visited/hover state). Forcing the decoration to
inherit the heading's actual values fixes that everywhere a title is
wrapped in a link, without needing a class on every call site. */
h1 a,
h2 a,
h3 a {
text-decoration-line: inherit;
text-decoration-color: inherit;
text-decoration-thickness: inherit;
text-underline-offset: inherit;
text-decoration-skip-ink: inherit;
}
p {
margin: 0 0 var(--space-5);
}
@@ -93,6 +100,22 @@ pre {
border: 1px solid var(--border);
}
.youtube-wrapper {
position: relative;
aspect-ratio: 16 / 9;
margin: 0 0 var(--space-5);
border-radius: var(--radius-md);
overflow: hidden;
}
.youtube-wrapper iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
/* highlight.js syntax highlighting (toml-content-loader.ts renders code
fences through hljs's core build). Colors are drawn from the existing
design tokens, so they already switch with the site's light/dark theme
@@ -112,16 +135,47 @@ pre {
color: var(--accent);
}
/* #4a9c6d / #d1a53d have plenty of contrast on the dark theme's code-block
background but fail WCAG AA on the light one (2.8:1 / 1.9:1) — same issue
as the callout colors above, fixed the same way: darker variants for
light mode, originals restored for dark mode. */
.hljs-string,
.hljs-attr,
.hljs-symbol,
.hljs-addition {
color: #4a9c6d;
color: #377451;
}
.hljs-number,
.hljs-meta,
.hljs-doctag {
color: #82641f;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .hljs-string,
:root:not([data-theme="light"]) .hljs-attr,
:root:not([data-theme="light"]) .hljs-symbol,
:root:not([data-theme="light"]) .hljs-addition {
color: #4a9c6d;
}
:root:not([data-theme="light"]) .hljs-number,
:root:not([data-theme="light"]) .hljs-meta,
:root:not([data-theme="light"]) .hljs-doctag {
color: #d1a53d;
}
}
:root[data-theme="dark"] .hljs-string,
:root[data-theme="dark"] .hljs-attr,
:root[data-theme="dark"] .hljs-symbol,
:root[data-theme="dark"] .hljs-addition {
color: #4a9c6d;
}
:root[data-theme="dark"] .hljs-number,
:root[data-theme="dark"] .hljs-meta,
:root[data-theme="dark"] .hljs-doctag {
color: #d1a53d;
}
@@ -134,7 +188,7 @@ pre {
}
.hljs-deletion {
color: #d1502f;
color: var(--accent);
}
.hljs-emphasis {
@@ -192,9 +246,13 @@ pre {
.eyebrow {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
text-decoration: none;
color: var(--text-muted);
margin: 0 0 var(--space-5);
}
.meta {
@@ -221,15 +279,24 @@ pre {
border-bottom: 1px solid var(--border);
}
.card h3 {
/* Semantically these list-item titles are h2 (the page's only other
heading is its own h1, with nothing at h2 between them) — sized down to
what used to be the h3 scale so going from h3 to h2 doesn't change the
visual hierarchy, just the accessibility tree. */
.entry-title {
margin-bottom: var(--space-1);
font-size: var(--font-size-xl);
}
.card h3 a {
text-decoration: none;
.entry-title a {
text-decoration-line: underline;
text-decoration-color: var(--accent);
text-decoration-thickness: 0.1em;
text-underline-offset: 0.08em;
text-decoration-skip-ink: auto;
}
.card h3 a:hover {
.entry-title a:hover {
color: var(--accent);
}
@@ -298,16 +365,83 @@ pre {
--callout-color: var(--accent);
}
/* Dark-mode-only values: #4a9c6d and #d1a53d have plenty of contrast on the
dark --bg-elevated (5.4:1 / 7.9:1) but fail WCAG AA on the light one
(2.8:1 / 1.9:1), so light mode gets darkened variants instead of one
hardcoded color for both themes. */
.callout-tip {
--callout-color: #4a9c6d;
--callout-color: #377451;
}
.callout-warning {
--callout-color: #d1a53d;
--callout-color: #82641f;
}
.callout-danger {
--callout-color: #d1502f;
--callout-color: var(--accent);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .callout-tip {
--callout-color: #4a9c6d;
}
:root:not([data-theme="light"]) .callout-warning {
--callout-color: #d1a53d;
}
}
:root[data-theme="dark"] .callout-tip {
--callout-color: #4a9c6d;
}
:root[data-theme="dark"] .callout-warning {
--callout-color: #d1a53d;
}
.post-nav {
display: flex;
flex-direction: column;
gap: var(--space-5);
margin-top: var(--space-7);
padding-top: var(--space-6);
border-top: 1px solid var(--border);
}
.post-nav-link {
display: flex;
flex-direction: column;
gap: var(--space-1);
text-decoration: none;
}
.post-nav-label {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--accent);
}
.post-nav-title {
font-family: var(--font-display);
font-style: italic;
color: var(--text);
transition: color 0.2s var(--ease-out);
}
.post-nav-link:hover .post-nav-title {
color: var(--accent);
}
@media (min-width: 40rem) {
.post-nav {
flex-direction: row;
justify-content: space-between;
gap: var(--space-6);
}
.post-nav-next {
align-items: flex-end;
text-align: right;
}
}
@media (prefers-reduced-motion: reduce) {
+5 -2
View File
@@ -10,8 +10,11 @@
--text: #1c1a15;
--text-muted: #6f6a5c;
--border: rgba(28, 26, 21, 0.12);
--accent: #d1502f;
--accent-muted: rgba(209, 80, 47, 0.12);
/* #d1502f measured 4.0:1 on --bg, just under WCAG AA's 4.5:1 for normal
text (used at small sizes in nav/tags/links) — darkened to 5.7:1 while
keeping the same hue. */
--accent: #a84025;
--accent-muted: rgba(168, 64, 37, 0.12);
--font-display: "Fraunces Variable", ui-serif, Georgia, serif;
--font-body: "Newsreader Variable", ui-serif, Georgia, serif;
+12
View File
@@ -104,3 +104,15 @@ feed_url = "https://.../rss.xml"
Body is a one-to-two sentence blurb on why you follow it. No live feed
fetching — this list is hand-curated and static.
## Markdown extras (all body content)
- **LaTeX**: `$inline$` and `$$block$$` math renders to static HTML/MathML
at build time (`marked-katex-extension` in `toml-content-loader.ts`) — no
client-side JS shipped for it.
- **Callouts**: `{% <note> %} ... {% </note> %}` (also `tip`/`warning`/
`danger`) renders as a styled callout box.
- **YouTube embeds**: paste a plain `<iframe src="https://www.youtube-
nocookie.com/embed/VIDEO_ID" ...></iframe>` — it's automatically wrapped
in a responsive 16:9 container, gets `loading="lazy"`, and emits
`VideoObject` structured data. No shortcode needed.
+3 -4
View File
@@ -7,13 +7,12 @@
"dev": "concurrently -n site,cms -c blue,magenta \"pnpm --filter site dev\" \"pnpm --filter cms develop\"",
"dev:site": "pnpm --filter site dev",
"dev:cms": "pnpm --filter cms develop",
"build": "pnpm run build:site && pnpm run build:cms",
"build:site": "pnpm --filter site build",
"build:cms": "pnpm --filter cms build",
"lighthouse": "pnpm --filter site lighthouse",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"legacy:generate:llms": "node scripts/generate-llms.mjs",
"legacy:generate:og": "node scripts/generate-og-images.mjs",
"legacy:build": "npm run legacy:generate:llms && npm run legacy:generate:og && zola build"
"lint:fix": "biome check --write ."
},
"devDependencies": {
"@biomejs/biome": "^2.5.11",
+1455 -4
View File
File diff suppressed because it is too large Load Diff
-182
View File
@@ -1,182 +0,0 @@
// One-off migration: converts content/posts/*.md (legacy Zola posts) into
// content/writings/<slug>/index.md (new schema). Additive only — never
// touches content/posts/. Re-runnable: overwrites its own prior output.
import fs from "node:fs";
import path from "node:path";
import {
parse as parseToml,
stringify as stringifyToml,
TomlDate,
} from "smol-toml";
const root = process.cwd();
const postsDir = path.join(root, "content", "posts");
const writingsDir = path.join(root, "content", "writings");
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return entry.isFile() && entry.name.endsWith(".md") ? [full] : [];
});
}
function slugFor(file) {
const rel = path.relative(postsDir, file).split(path.sep).join("/");
return rel.endsWith("/index.md")
? rel.slice(0, -"/index.md".length)
: rel.slice(0, -".md".length);
}
function compact(obj) {
const out = {};
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null || value === "") continue;
if (Array.isArray(value) && value.length === 0) continue;
if (
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Date)
) {
const nested = compact(value);
if (Object.keys(nested).length === 0) continue;
out[key] = nested;
continue;
}
out[key] = value;
}
return out;
}
function normalizeTag(tag) {
return tag.trim().toLowerCase().replace(/\s+/g, "-");
}
function toIsoDate(value) {
if (value instanceof Date)
return new TomlDate(value.toISOString().slice(0, 10));
if (typeof value === "string") return new TomlDate(value.slice(0, 10));
return undefined;
}
/** Converts the one unambiguous Zola shortcode in use (youtube embeds) to a
* plain HTML iframe, and strips Zola's `{% raw %}`/`{% endraw %}` escape
* tags (meaningless now — there's no template engine processing the body).
* Anything else Zola-specific (note/tip/warning/danger callouts) is left
* as-is: those need real Astro components before they can be translated,
* so they're flagged for manual follow-up instead of guessed at. */
function migrateBody(body) {
const flags = [];
const out = body
.replace(/\{%\s*raw\s*%\}\r?\n?/g, "")
.replace(/\{%\s*endraw\s*%\}\r?\n?/g, "")
.replace(/\{\{<\s*youtube\s+id="([^"]+)"[\s\S]*?\/>\s*\}\}/g, (_m, id) => {
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube video" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
});
if (/\{%\s*<?\/?(note|tip|warning|danger)>?\s*%\}/.test(out)) {
flags.push("callout-shortcode");
}
if (/\{\{|\{%/.test(out)) {
flags.push("unresolved-template-syntax");
}
return { body: out, flags };
}
const files = walk(postsDir).filter((f) => path.basename(f) !== "_index.md");
fs.mkdirSync(writingsDir, { recursive: true });
let migrated = 0;
const flaggedFiles = [];
const migratedSlugs = [];
for (const file of files) {
const raw = fs.readFileSync(file, "utf8");
const match = raw.match(FRONTMATTER_RE);
if (!match) continue;
const [, fmSource, body] = match;
const fm = parseToml(fmSource);
const slug = slugFor(file);
const { body: newBody, flags } = migrateBody(body.trim());
if (flags.length) flaggedFiles.push({ slug, flags });
const frontmatter = stringifyToml(
compact({
title: fm.title,
description: fm.description,
author: fm.author,
date: toIsoDate(fm.date),
updated: toIsoDate(fm.updated),
draft: fm.draft ?? false,
canonical_url: fm.canonical_url,
taxonomies: { tags: (fm.taxonomies?.tags ?? []).map(normalizeTag) },
extra: {
series: fm.extra?.series,
series_index: fm.extra?.series_index,
llms_description: fm.extra?.llms_description,
},
}),
);
const outDir = path.join(writingsDir, slug);
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(
path.join(outDir, "index.md"),
`+++\n${frontmatter}\n+++\n\n${newBody}\n`,
);
migrated += 1;
migratedSlugs.push(slug);
}
// Keep old /posts/<slug>/ URLs alive (RSS subscribers, inbound links) by
// redirecting to the new /writings/<slug>/ path. Regenerated idempotently
// inside marker comments so re-running this script doesn't duplicate or
// clobber the rest of sws.toml.
const swsPath = path.join(root, "sws.toml");
const BEGIN =
"# BEGIN migrated-posts-redirects (generated by scripts/migrate-posts-to-writings.mjs)";
const END = "# END migrated-posts-redirects";
const redirectBlock = [
BEGIN,
...migratedSlugs
.sort()
.flatMap((slug) => [
"[[advanced.redirects]]",
`source = "/posts/${slug}/"`,
`destination = "/writings/${slug}/"`,
"kind = 301",
"",
]),
END,
]
.join("\n")
.trimEnd();
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const swsSource = fs.readFileSync(swsPath, "utf8");
const markerRe = new RegExp(
`${escapeRegExp(BEGIN)}[\\s\\S]*?${escapeRegExp(END)}`,
);
const updatedSws = markerRe.test(swsSource)
? swsSource.replace(markerRe, redirectBlock)
: `${swsSource.trimEnd()}\n\n${redirectBlock}\n`;
fs.writeFileSync(swsPath, `${updatedSws.trimEnd()}\n`);
console.log(
`Migrated ${migrated} post(s) from content/posts/ to content/writings/.`,
);
console.log(`Wrote ${migratedSlugs.length} redirect(s) to sws.toml.`);
if (flaggedFiles.length) {
console.log(
"\nNeeds manual follow-up (Zola-specific syntax with no Astro equivalent yet):",
);
for (const { slug, flags } of flaggedFiles) {
console.log(` - ${slug}: ${flags.join(", ")}`);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ page404 = "./404.html"
[[advanced.headers]]
source = "**"
[advanced.headers.headers]
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' https://utteranc.es https://analytics.andwati.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://analytics.andwati.com; frame-src https://www.youtube-nocookie.com https://utteranc.es; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"