mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 01:12:53 +00:00
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:
@@ -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()],
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
};
|
||||
Generated
+825
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user