mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 09:22:53 +00:00
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:
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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=
|
||||
@@ -17,6 +17,9 @@ pnpm-debug.log*
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# Lighthouse CI report artifacts
|
||||
.lighthouseci/
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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()}</>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1455
-4
File diff suppressed because it is too large
Load Diff
@@ -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(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user