diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 0000000..3e294ec --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e25a835..36d513d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -60,6 +60,21 @@ CONTENT_GIT_COMMIT_NAME="andwati.com CMS" CONTENT_GIT_COMMIT_EMAIL=andwati-cms@users.noreply.github.com ``` +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`. diff --git a/TODO.md b/TODO.md index 52ae7f6..a07b834 100644 --- a/TODO.md +++ b/TODO.md @@ -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` diff --git a/apps/site/.env.example b/apps/site/.env.example new file mode 100644 index 0000000..c49d730 --- /dev/null +++ b/apps/site/.env.example @@ -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= diff --git a/apps/site/.gitignore b/apps/site/.gitignore index 16d54bb..9f8dd10 100644 --- a/apps/site/.gitignore +++ b/apps/site/.gitignore @@ -17,6 +17,9 @@ pnpm-debug.log* .env .env.production +# Lighthouse CI report artifacts +.lighthouseci/ + # macOS-specific files .DS_Store diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs index ec54e64..6a4a03c 100644 --- a/apps/site/astro.config.mjs +++ b/apps/site/astro.config.mjs @@ -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, 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; + }, + }), + ], }); diff --git a/apps/site/lib-config/sitemap-lastmod.mjs b/apps/site/lib-config/sitemap-lastmod.mjs new file mode 100644 index 0000000..591ef98 --- /dev/null +++ b/apps/site/lib-config/sitemap-lastmod.mjs @@ -0,0 +1,65 @@ +// Builds a Map from content/ frontmatter so the +// sitemap's 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"); +} diff --git a/apps/site/lighthouserc.cjs b/apps/site/lighthouserc.cjs new file mode 100644 index 0000000..0e147d6 --- /dev/null +++ b/apps/site/lighthouserc.cjs @@ -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", + }, + }, +}; diff --git a/apps/site/lighthouserc.desktop.cjs b/apps/site/lighthouserc.desktop.cjs new file mode 100644 index 0000000..512be4e --- /dev/null +++ b/apps/site/lighthouserc.desktop.cjs @@ -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" }, + }, + }, +}; diff --git a/apps/site/package.json b/apps/site/package.json index b10c922..3b1fb11 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -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" } diff --git a/apps/site/public/llms.txt b/apps/site/public/llms.txt index ddabeb7..39ee222 100644 --- a/apps/site/public/llms.txt +++ b/apps/site/public/llms.txt @@ -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 diff --git a/apps/site/public/robots.txt b/apps/site/public/robots.txt index 09d32b3..50f69f6 100644 --- a/apps/site/public/robots.txt +++ b/apps/site/public/robots.txt @@ -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 diff --git a/apps/site/src/components/Analytics.astro b/apps/site/src/components/Analytics.astro new file mode 100644 index 0000000..35e1ccc --- /dev/null +++ b/apps/site/src/components/Analytics.astro @@ -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 && ( + diff --git a/apps/site/src/components/Comments.astro b/apps/site/src/components/Comments.astro new file mode 100644 index 0000000..b86ba0c --- /dev/null +++ b/apps/site/src/components/Comments.astro @@ -0,0 +1,84 @@ +--- +const repo = "andwati/andwati.com"; +const themeDark = "gruvbox-dark"; +const themeLight = "github-light"; +--- + +
+ +
+ + + + diff --git a/apps/site/src/components/Constellation.astro b/apps/site/src/components/Constellation.astro index ff824b6..47a1283 100644 --- a/apps/site/src/components/Constellation.astro +++ b/apps/site/src/components/Constellation.astro @@ -52,19 +52,46 @@ const graph = await buildConstellationGraph(); diff --git a/apps/site/src/components/Minimap.astro b/apps/site/src/components/Minimap.astro new file mode 100644 index 0000000..5a36c8b --- /dev/null +++ b/apps/site/src/components/Minimap.astro @@ -0,0 +1,160 @@ +--- +--- + + + + + + diff --git a/apps/site/src/components/ReadingProgress.astro b/apps/site/src/components/ReadingProgress.astro new file mode 100644 index 0000000..b06fe2d --- /dev/null +++ b/apps/site/src/components/ReadingProgress.astro @@ -0,0 +1,63 @@ +--- +--- + + + + + + diff --git a/apps/site/src/components/ScrollToTop.astro b/apps/site/src/components/ScrollToTop.astro new file mode 100644 index 0000000..dcf7dbb --- /dev/null +++ b/apps/site/src/components/ScrollToTop.astro @@ -0,0 +1,105 @@ +--- +--- + + + + + + diff --git a/apps/site/src/components/SearchModal.astro b/apps/site/src/components/SearchModal.astro new file mode 100644 index 0000000..136e9ed --- /dev/null +++ b/apps/site/src/components/SearchModal.astro @@ -0,0 +1,193 @@ +--- +--- + + + + + + diff --git a/apps/site/src/components/ShareButtons.astro b/apps/site/src/components/ShareButtons.astro new file mode 100644 index 0000000..633c1af --- /dev/null +++ b/apps/site/src/components/ShareButtons.astro @@ -0,0 +1,164 @@ +--- +interface Props { + title: string; + url: string; +} + +const { title, url } = Astro.props; +const encodedTitle = encodeURIComponent(title); +const encodedUrl = encodeURIComponent(url); +--- + + + + diff --git a/apps/site/src/content.config.ts b/apps/site/src/content.config.ts index 581a56a..62ef245 100644 --- a/apps/site/src/content.config.ts +++ b/apps/site/src/content.config.ts @@ -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({ diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro index afcf47a..7e4eeb4 100644 --- a/apps/site/src/layouts/Base.astro +++ b/apps/site/src/layouts/Base.astro @@ -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]) : []; --- @@ -28,10 +69,13 @@ const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href; {pageTitle} + + - - + + + @@ -40,8 +84,18 @@ const ogImageUrl = new URL(ogImage ?? "/favicon.svg", siteConfig.baseUrl).href; -