Add feeds, sitemap, search, tags, and per-post nav/TOC/series/related

- src/site.config.ts: minimal site metadata (title, description,
  base URL, nav, tag descriptions) ported from the legacy zola.toml
  as a placeholder pending real design/copy.
- RSS (@astrojs/rss) and JSON Feed endpoints, sitemap
  (@astrojs/sitemap), robots.txt.
- Base.astro: meta description (site-wide default or per-page
  override via llms_description/description), RSS/JSON feed
  <link rel=alternate>, and a webmention endpoint link (plumbing
  only — needs the domain registered on webmention.io to do
  anything).
- toml-content-loader.ts: marked renderer now assigns heading ids
  and collects them into rendered.metadata.headings, so
  render(entry) exposes headings the same way a built-in Markdown
  loader would.
- Writing detail page: table of contents, prev/next navigation,
  series-nav (uses the existing extra.series/series_index fields),
  and tag-overlap related posts.
- Tags index + per-tag pages, tag links wired in from writing cards.
- Pagefind: indexes the build output as part of `pnpm build`
  (astro build && pagefind --site dist), plus a /search/ page using
  Pagefind's default UI.

Verified: astro check clean, full build succeeds (feeds/sitemap/
pagefind all generate correctly), and manually exercised /search/
(real query, highlighted results) and /tags/ in a browser against
the built site.
This commit is contained in:
2026-08-30 19:04:25 +03:00
parent 7b857d4be7
commit 02a7a6197f
13 changed files with 1145 additions and 9 deletions
+6 -1
View File
@@ -1,5 +1,10 @@
// @ts-check
import sitemap from "@astrojs/sitemap";
import { defineConfig } from "astro/config";
import { siteConfig } from "./src/site.config.ts";
// https://astro.build/config
export default defineConfig({});
export default defineConfig({
site: siteConfig.baseUrl,
integrations: [sitemap()],
});
+9 -1
View File
@@ -7,16 +7,24 @@
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build": "astro build && pagefind --site dist",
"preview": "astro preview",
"check": "astro check",
"astro": "astro"
},
"dependencies": {
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.3",
"astro": "^7.2.9",
"marked": "^18.0.11",
"pagefind": "^1.5.2",
"smol-toml": "^1.8.0"
},
"allowScripts": {
"esbuild": true
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"typescript": "^5.9.3"
}
}
+11 -1
View File
@@ -1,8 +1,12 @@
---
import { siteConfig } from "../site.config";
interface Props {
title: string;
description?: string;
}
const { title } = Astro.props;
const { title, description } = Astro.props;
const metaDescription = description ?? siteConfig.description;
---
<html lang="en">
@@ -10,6 +14,10 @@ const { title } = Astro.props;
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — andwati.com</title>
<meta name="description" content={metaDescription} />
<link rel="alternate" type="application/rss+xml" title={siteConfig.title} href="/rss.xml" />
<link rel="alternate" type="application/feed+json" title={siteConfig.title} href="/feed.json" />
<link rel="webmention" href="https://webmention.io/andwati.com/webmention" />
<style>
:root {
color-scheme: light dark;
@@ -54,6 +62,8 @@ const { title } = Astro.props;
<a href="/portfolio/">Portfolio</a>
<a href="/bookshelf/">Bookshelf</a>
<a href="/blogs/">Blogs</a>
<a href="/tags/">Tags</a>
<a href="/search/">Search</a>
</nav>
<main>
<slot />
+35 -2
View File
@@ -1,11 +1,39 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join, relative } from "node:path";
import type { Loader } from "astro/loaders";
import { marked } from "marked";
import { Marked } from "marked";
import { parse as parseToml } from "smol-toml";
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
interface Heading {
depth: number;
slug: string;
text: string;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// Collects headings for the file currently being parsed (reset before each
// `marked.parse()` call below) so Astro's `render(entry)` can expose them
// as `headings`, matching the shape a built-in markdown loader would give.
let currentHeadings: Heading[] = [];
const marked = new Marked({
renderer: {
heading(token) {
const slug = slugify(token.text);
currentHeadings.push({ depth: token.depth, slug, text: token.text });
return `<h${token.depth} id="${slug}">${token.text}</h${token.depth}>`;
},
},
});
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -51,8 +79,13 @@ export function tomlContentLoader(contentDir: string): Loader {
const data = parseToml(frontmatter) as Record<string, unknown>;
const relPath = relative(dirUrl.pathname, file);
const id = relPath.replace(/\/index\.md$/, "").replace(/\.md$/, "");
currentHeadings = [];
const html = await marked.parse(body.trim());
store.set({ id, data, rendered: { html } });
store.set({
id,
data,
rendered: { html, metadata: { headings: currentHeadings } },
});
}
},
};
+31
View File
@@ -0,0 +1,31 @@
import { getCollection } from "astro:content";
import { siteConfig } from "../site.config";
export async function GET() {
const writings = (
await getCollection("writings", ({ data }) => !data.draft)
).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const feed = {
version: "https://jsonfeed.org/version/1.1",
title: siteConfig.title,
home_page_url: siteConfig.baseUrl,
feed_url: `${siteConfig.baseUrl}/feed.json`,
description: siteConfig.description,
items: writings.map((entry) => ({
id: `${siteConfig.baseUrl}/writings/${entry.id}/`,
url: `${siteConfig.baseUrl}/writings/${entry.id}/`,
title: entry.data.title,
summary: entry.data.description,
date_published: entry.data.date.toISOString(),
...(entry.data.updated
? { date_modified: entry.data.updated.toISOString() }
: {}),
tags: entry.data.taxonomies?.tags ?? [],
})),
};
return new Response(JSON.stringify(feed, null, 2), {
headers: { "Content-Type": "application/feed+json; charset=utf-8" },
});
}
+23
View File
@@ -0,0 +1,23 @@
import { getCollection } from "astro:content";
import rss from "@astrojs/rss";
import type { APIContext } from "astro";
import { siteConfig } from "../site.config";
export async function GET(context: APIContext) {
const writings = (
await getCollection("writings", ({ data }) => !data.draft)
).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
return rss({
title: siteConfig.title,
description: siteConfig.description,
site: context.site ?? siteConfig.baseUrl,
items: writings.map((entry) => ({
title: entry.data.title,
description: entry.data.description,
pubDate: entry.data.date,
link: `/writings/${entry.id}/`,
categories: entry.data.taxonomies?.tags,
})),
});
}
+22
View File
@@ -0,0 +1,22 @@
---
import Base from "../layouts/Base.astro";
---
<Base title="Search">
<h1>Search</h1>
<p class="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 id="search"></div>
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script is:inline src="/pagefind/pagefind-ui.js"></script>
<script is:inline>
window.addEventListener("DOMContentLoaded", () => {
if (window.PagefindUI) {
// biome-ignore lint/correctness/noUndeclaredVariables: loaded from /pagefind/pagefind-ui.js above
new PagefindUI({ element: "#search", showSubResults: true });
}
});
</script>
</Base>
+35
View File
@@ -0,0 +1,35 @@
---
import { getCollection } from "astro:content";
import Base from "../../layouts/Base.astro";
import { siteConfig } from "../../site.config";
export async function getStaticPaths() {
const writings = await getCollection("writings", ({ data }) => !data.draft);
const tags = new Set<string>();
for (const w of writings) {
for (const t of w.data.taxonomies?.tags ?? []) tags.add(t);
}
return [...tags].map((tag) => ({ params: { tag }, props: { tag } }));
}
const { tag } = Astro.props;
const writings = (await getCollection("writings", ({ data }) => !data.draft))
.filter((w) => (w.data.taxonomies?.tags ?? []).includes(tag))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const description = siteConfig.tagDescriptions[tag];
---
<Base title={`#${tag}`} description={description}>
<h1>#{tag}</h1>
{description && <p>{description}</p>}
{
writings.map((entry) => (
<div class="card">
<h3>
<a href={`/writings/${entry.id}/`}>{entry.data.title}</a>
</h3>
<p class="meta">{entry.data.date.toDateString()}</p>
</div>
))
}
</Base>
+26
View File
@@ -0,0 +1,26 @@
---
import { getCollection } from "astro:content";
import Base from "../../layouts/Base.astro";
const writings = await getCollection("writings", ({ data }) => !data.draft);
const tagCounts = new Map<string, number>();
for (const w of writings) {
for (const tag of w.data.taxonomies?.tags ?? []) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
const tags = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]);
---
<Base title="All Tags">
<h1>All Tags</h1>
<ul>
{
tags.map(([tag, count]) => (
<li>
<a href={`/tags/${tag}/`}>{tag}</a> ({count})
</li>
))
}
</ul>
</Base>
+93 -3
View File
@@ -11,17 +11,107 @@ export async function getStaticPaths() {
}
const { entry } = Astro.props;
const { Content } = await render(entry);
const { Content, headings } = await render(entry);
const allWritings = (
await getCollection("writings", ({ data }) => !data.draft)
).sort((a, b) => a.data.date.valueOf() - b.data.date.valueOf());
const index = allWritings.findIndex((w) => w.id === entry.id);
const prev = index > 0 ? allWritings[index - 1] : undefined;
const next =
index < allWritings.length - 1 ? allWritings[index + 1] : undefined;
const series = entry.data.extra?.series;
const seriesEntries = series
? allWritings
.filter((w) => w.data.extra?.series === series)
.sort(
(a, b) =>
(a.data.extra?.series_index ?? 0) - (b.data.extra?.series_index ?? 0),
)
: [];
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 description =
entry.data.extra?.llms_description ?? entry.data.description;
const tocHeadings = headings.filter((h) => h.depth <= 3);
---
<Base title={entry.data.title}>
<Base title={entry.data.title} description={description}>
<h1>{entry.data.title}</h1>
<p class="meta">
{entry.data.date.toDateString()}
{entry.data.updated && <> · updated {entry.data.updated.toDateString()}</>}
</p>
{entry.data.taxonomies?.tags.map((tag) => <span class="tag">#{tag}</span>)}
{tags.map((tag) => <a class="tag" href={`/tags/${tag}/`}>#{tag}</a>)}
{
series && (
<nav class="series-nav" aria-label={`Post series: ${series}`}>
<p class="meta">
Series: {series} ({(entry.data.extra?.series_index ?? 0)} / {seriesEntries.length})
</p>
<ol>
{seriesEntries.map((w) => (
<li aria-current={w.id === entry.id ? "page" : undefined}>
<a href={`/writings/${w.id}/`}>{w.data.title}</a>
</li>
))}
</ol>
</nav>
)
}
{
tocHeadings.length > 0 && (
<nav class="toc" aria-label="Table of contents">
<p class="meta">Table of Contents</p>
<ul>
{tocHeadings.map((h) => (
<li style={{ marginLeft: `${(h.depth - 2) * 1}rem` }}>
<a href={`#${h.slug}`}>{h.text}</a>
</li>
))}
</ul>
</nav>
)
}
<article>
<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>
{
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>
)
}
</Base>
+1 -1
View File
@@ -15,7 +15,7 @@ const writings = (
<h3><a href={`/writings/${entry.id}/`}>{entry.data.title}</a></h3>
<p class="meta">{entry.data.date.toDateString()}</p>
{entry.data.description && <p>{entry.data.description}</p>}
{entry.data.taxonomies?.tags.map((tag) => <span class="tag">#{tag}</span>)}
{entry.data.taxonomies?.tags.map((tag) => <a class="tag" href={`/tags/${tag}/`}>#{tag}</a>)}
</div>
))
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Site-wide metadata. Placeholder copy — ported from the legacy zola.toml
* values as a reasonable default, pending a real editorial pass once the
* design system lands.
*/
export const siteConfig = {
title: "Thoughts and Musings",
description: "Computer science, mathematics, life, and beliefs.",
baseUrl: "https://andwati.com",
author: "andwati",
llmsDescription:
"Ian Andwati's personal digital-legacy site covering computer science, mathematics, life, and beliefs — writings, portfolio, and a curated bookshelf.",
nav: [
{ name: "Writings", url: "/writings/" },
{ name: "Portfolio", url: "/portfolio/" },
{ name: "Bookshelf", url: "/bookshelf/" },
{ name: "Blogs", url: "/blogs/" },
],
tagDescriptions: {
linux:
"Practical notes on running Arch-based Linux distributions, from WiFi hotspot quirks to database setup.",
"subresource-integrity":
"How Subresource Integrity (SRI) hashes protect a site from tampered third-party scripts and stylesheets.",
garuda:
"Setup notes for Garuda Linux, an Arch-based distribution built for performance.",
cors: "Notes on Cross-Origin Resource Sharing (CORS) and how browsers enforce cross-origin security policies.",
} as Record<string, string>,
};
+825
View File
File diff suppressed because it is too large Load Diff