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

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

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

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

Removed the related-posts section per request.

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

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

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

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

Lighthouse CI
- @lhci/cli wired up against the built site (mobile + desktop configs),
  `pnpm run lighthouse`, and a GitHub Actions workflow to run it on every
  push/PR — reporting only for now, no failing thresholds
This commit is contained in:
2026-09-12 20:40:16 +03:00
parent ffc3b6f353
commit 12dd32ea4f
44 changed files with 3194 additions and 370 deletions
-182
View File
@@ -1,182 +0,0 @@
// One-off migration: converts content/posts/*.md (legacy Zola posts) into
// content/writings/<slug>/index.md (new schema). Additive only — never
// touches content/posts/. Re-runnable: overwrites its own prior output.
import fs from "node:fs";
import path from "node:path";
import {
parse as parseToml,
stringify as stringifyToml,
TomlDate,
} from "smol-toml";
const root = process.cwd();
const postsDir = path.join(root, "content", "posts");
const writingsDir = path.join(root, "content", "writings");
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return entry.isFile() && entry.name.endsWith(".md") ? [full] : [];
});
}
function slugFor(file) {
const rel = path.relative(postsDir, file).split(path.sep).join("/");
return rel.endsWith("/index.md")
? rel.slice(0, -"/index.md".length)
: rel.slice(0, -".md".length);
}
function compact(obj) {
const out = {};
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null || value === "") continue;
if (Array.isArray(value) && value.length === 0) continue;
if (
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Date)
) {
const nested = compact(value);
if (Object.keys(nested).length === 0) continue;
out[key] = nested;
continue;
}
out[key] = value;
}
return out;
}
function normalizeTag(tag) {
return tag.trim().toLowerCase().replace(/\s+/g, "-");
}
function toIsoDate(value) {
if (value instanceof Date)
return new TomlDate(value.toISOString().slice(0, 10));
if (typeof value === "string") return new TomlDate(value.slice(0, 10));
return undefined;
}
/** Converts the one unambiguous Zola shortcode in use (youtube embeds) to a
* plain HTML iframe, and strips Zola's `{% raw %}`/`{% endraw %}` escape
* tags (meaningless now — there's no template engine processing the body).
* Anything else Zola-specific (note/tip/warning/danger callouts) is left
* as-is: those need real Astro components before they can be translated,
* so they're flagged for manual follow-up instead of guessed at. */
function migrateBody(body) {
const flags = [];
const out = body
.replace(/\{%\s*raw\s*%\}\r?\n?/g, "")
.replace(/\{%\s*endraw\s*%\}\r?\n?/g, "")
.replace(/\{\{<\s*youtube\s+id="([^"]+)"[\s\S]*?\/>\s*\}\}/g, (_m, id) => {
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube video" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
});
if (/\{%\s*<?\/?(note|tip|warning|danger)>?\s*%\}/.test(out)) {
flags.push("callout-shortcode");
}
if (/\{\{|\{%/.test(out)) {
flags.push("unresolved-template-syntax");
}
return { body: out, flags };
}
const files = walk(postsDir).filter((f) => path.basename(f) !== "_index.md");
fs.mkdirSync(writingsDir, { recursive: true });
let migrated = 0;
const flaggedFiles = [];
const migratedSlugs = [];
for (const file of files) {
const raw = fs.readFileSync(file, "utf8");
const match = raw.match(FRONTMATTER_RE);
if (!match) continue;
const [, fmSource, body] = match;
const fm = parseToml(fmSource);
const slug = slugFor(file);
const { body: newBody, flags } = migrateBody(body.trim());
if (flags.length) flaggedFiles.push({ slug, flags });
const frontmatter = stringifyToml(
compact({
title: fm.title,
description: fm.description,
author: fm.author,
date: toIsoDate(fm.date),
updated: toIsoDate(fm.updated),
draft: fm.draft ?? false,
canonical_url: fm.canonical_url,
taxonomies: { tags: (fm.taxonomies?.tags ?? []).map(normalizeTag) },
extra: {
series: fm.extra?.series,
series_index: fm.extra?.series_index,
llms_description: fm.extra?.llms_description,
},
}),
);
const outDir = path.join(writingsDir, slug);
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(
path.join(outDir, "index.md"),
`+++\n${frontmatter}\n+++\n\n${newBody}\n`,
);
migrated += 1;
migratedSlugs.push(slug);
}
// Keep old /posts/<slug>/ URLs alive (RSS subscribers, inbound links) by
// redirecting to the new /writings/<slug>/ path. Regenerated idempotently
// inside marker comments so re-running this script doesn't duplicate or
// clobber the rest of sws.toml.
const swsPath = path.join(root, "sws.toml");
const BEGIN =
"# BEGIN migrated-posts-redirects (generated by scripts/migrate-posts-to-writings.mjs)";
const END = "# END migrated-posts-redirects";
const redirectBlock = [
BEGIN,
...migratedSlugs
.sort()
.flatMap((slug) => [
"[[advanced.redirects]]",
`source = "/posts/${slug}/"`,
`destination = "/writings/${slug}/"`,
"kind = 301",
"",
]),
END,
]
.join("\n")
.trimEnd();
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const swsSource = fs.readFileSync(swsPath, "utf8");
const markerRe = new RegExp(
`${escapeRegExp(BEGIN)}[\\s\\S]*?${escapeRegExp(END)}`,
);
const updatedSws = markerRe.test(swsSource)
? swsSource.replace(markerRe, redirectBlock)
: `${swsSource.trimEnd()}\n\n${redirectBlock}\n`;
fs.writeFileSync(swsPath, `${updatedSws.trimEnd()}\n`);
console.log(
`Migrated ${migrated} post(s) from content/posts/ to content/writings/.`,
);
console.log(`Wrote ${migratedSlugs.length} redirect(s) to sws.toml.`);
if (flaggedFiles.length) {
console.log(
"\nNeeds manual follow-up (Zola-specific syntax with no Astro equivalent yet):",
);
for (const { slug, flags } of flaggedFiles) {
console.log(` - ${slug}: ${flags.join(", ")}`);
}
}