site: topics axis + per-source indexes

Topics — the curated subscription layer: 18-value controlled vocabulary
in src/lib/topics.ts, enum-enforced by the content schema on finds and
reviews so unattended runs can't drift it. /topics/ index +
/topics/<t>/ pages with finds first-class alongside reviews, per-topic
RSS feeds that include finds (unlike category/tag feeds), "By topic"
section on /feeds/, Topics footer link and llms.txt section, dev-shim
coverage for the new feed URLs. daily-finds and build-review skills now
classify at capture/graduation time; "giftable" is a tag, never a
topic.

Per-source indexes: /sources/<slug>/ lists every review and find from a
source (catalog ∪ names in content, so retired sources resolve);
find/review colophons link "(more from this source)"; /sources/ cards
link "everything from this source →"; sub-3-item pages noindexed.

Claude-Session: https://claude.ai/code/session_01WZaczDJjL3xZ3u5spsN5AL
This commit is contained in:
2026-07-12 19:39:11 -04:00
parent be7263b912
commit 9e5fce1eb0
21 changed files with 695 additions and 14 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { TOPICS } from './lib/topics';
const stripIndex = ({ entry }: { entry: string }) =>
entry.replace(/\/index\.mdx$/, '');
@@ -18,6 +19,7 @@ const reviews = defineCollection({
subtitle: z.string(),
date: z.coerce.date(),
category: z.string(),
topics: z.array(z.enum(TOPICS)).default([]),
tags: z.array(z.string()).default([]),
link: z.string().url().optional(),
linkText: z.string().optional(),
@@ -57,7 +59,7 @@ const find = defineCollection({
linkText: z.string().optional(),
amazonLink: z.string().url().optional(),
source: z.string(),
topics: z.array(z.string()).default([]),
topics: z.array(z.enum(TOPICS)).default([]),
tags: z.array(z.string()).default([]),
description: z.string().optional(),
promotedTo: z.string().optional(),
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+7
View File
@@ -134,6 +134,7 @@ const year = new Date().getFullYear();
<ul>
<li><a href="/">Home</a></li>
<li><a href="/grid/">Grid</a></li>
<li><a href="/topics/">Topics</a></li>
<li><a href="/sources/">Sources</a></li>
<li><a href="/feeds/">Feeds</a></li>
</ul>
@@ -520,6 +521,12 @@ const year = new Date().getFullYear();
border-bottom-color: currentColor;
text-decoration: none;
}
.colophon .source-more {
margin-left: 0.45rem;
font-size: 0.72rem;
border-bottom: 0;
font-style: italic;
}
.colophon .colophon-tags {
display: flex;
flex-wrap: wrap;
+42 -10
View File
@@ -1,5 +1,6 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { getCollection, type CollectionEntry } from 'astro:content';
import type { Topic } from './topics';
/**
* One source of truth for everything syndicated: the main /rss.xml and
@@ -28,6 +29,28 @@ export function getFeedItems(): Promise<FeedItem[]> {
return itemsCache;
}
function reviewToFeedItem(r: CollectionEntry<'reviews'>): FeedItem {
return {
title: r.data.name,
description: r.data.description ?? r.data.subtitle ?? '',
pubDate: r.data.date,
link: `/reviews/${r.id}/`,
category: r.data.category,
tags: r.data.tags,
};
}
function findToFeedItem(f: CollectionEntry<'find'>): FeedItem {
return {
title: f.data.name,
description: f.data.description ?? f.data.subtitle ?? '',
pubDate: f.data.date,
link: `/find/${f.id}/`,
category: '',
tags: f.data.tags,
};
}
async function buildFeedItems(): Promise<FeedItem[]> {
const [reviews, posts, bundles] = await Promise.all([
getCollection('reviews'),
@@ -36,14 +59,7 @@ async function buildFeedItems(): Promise<FeedItem[]> {
]);
const items: FeedItem[] = [
...reviews.map((r) => ({
title: r.data.name,
description: r.data.description ?? r.data.subtitle ?? '',
pubDate: r.data.date,
link: `/reviews/${r.id}/`,
category: r.data.category,
tags: r.data.tags,
})),
...reviews.map(reviewToFeedItem),
...posts.map((p) => ({
title: p.data.title,
description: p.data.description ?? '',
@@ -65,6 +81,22 @@ async function buildFeedItems(): Promise<FeedItem[]> {
return items.sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
}
/**
* Items for a per-topic feed. Unlike the main/category/tag feeds, topic
* feeds include finds — topics are the subscription axis for tracking
* slices of the find stream.
*/
export async function getTopicFeedItems(topic: Topic): Promise<FeedItem[]> {
const [reviews, finds] = await Promise.all([
getCollection('reviews'),
getCollection('find'),
]);
return [
...reviews.filter((r) => r.data.topics.includes(topic)).map(reviewToFeedItem),
...finds.filter((f) => f.data.topics.includes(topic)).map(findToFeedItem),
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
}
/** Shape @astrojs/rss expects. */
export function toRssItems(items: FeedItem[]) {
return items.map((i) => ({
@@ -72,7 +104,7 @@ export function toRssItems(items: FeedItem[]) {
description: i.description,
pubDate: i.pubDate,
link: i.link,
categories: [i.category, ...i.tags],
categories: [i.category, ...i.tags].filter(Boolean),
}));
}
+37
View File
@@ -0,0 +1,37 @@
import { getCollection, type CollectionEntry } from 'astro:content';
import { TOPICS, type Topic } from './topics';
export type TopicSlice = {
topic: Topic;
reviews: CollectionEntry<'reviews'>[];
finds: CollectionEntry<'find'>[];
};
let cache: Promise<Map<Topic, TopicSlice>> | null = null;
/** Every topic's reviews and finds, each sorted newest-first. */
export function getTopicSlices(): Promise<Map<Topic, TopicSlice>> {
if (!cache) cache = buildSlices();
return cache;
}
async function buildSlices(): Promise<Map<Topic, TopicSlice>> {
const [reviews, finds] = await Promise.all([
getCollection('reviews'),
getCollection('find'),
]);
const map = new Map<Topic, TopicSlice>(
TOPICS.map((t) => [t, { topic: t, reviews: [], finds: [] }])
);
for (const r of reviews) {
for (const t of r.data.topics) map.get(t)!.reviews.push(r);
}
for (const f of finds) {
for (const t of f.data.topics) map.get(t)!.finds.push(f);
}
for (const slice of map.values()) {
slice.reviews.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
slice.finds.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
return map;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* The topics vocabulary — the coarse, curated subscription axis.
*
* Topics are the closed set of slices someone can follow (/topics/<t>/ pages
* and feeds, which include finds). Tags stay free-form and granular; facets
* like `open-source`, `free`, `bifl`, or `giftable` are tags, not topics.
*
* This list is the single source of truth: the content schema enum, the
* topic pages, the feeds, and the pipeline skills all derive from it.
* Adding a topic is an editorial decision — extend TOPIC_META, then
* reclassify or backfill as needed.
*/
export const TOPIC_META = {
'macos-apps': {
title: 'Mac apps & tips',
blurb: 'Menu-bar utilities, indie Mac software, and macOS tricks.',
},
'ios-apps': {
title: 'iPhone & iPad apps',
blurb: 'Apps and tips for iOS, iPadOS, and watchOS.',
},
'web-tools': {
title: 'Web tools & toys',
blurb: 'Browser-based tools, extensions, and playful websites.',
},
'cli-and-automation': {
title: 'CLI & automation',
blurb: 'Terminal tools, scripts, and things that do work for you.',
},
ai: {
title: 'AI',
blurb: 'Local models, agents, and AI tools that respect you.',
},
'writing-and-notes': {
title: 'Writing & notes',
blurb: 'Editors, markdown, journals, and tools for putting words down.',
},
productivity: {
title: 'Productivity',
blurb: 'Focus, planning, and getting-things-done tools.',
},
'design-and-art': {
title: 'Design & art',
blurb: 'Beautiful objects, art, architecture, and typography.',
},
'history-and-archives': {
title: 'History & archives',
blurb: 'Museums, archives, maps, and the delightfully old.',
},
'words-and-humor': {
title: 'Words & humor',
blurb: 'Puns, wordplay, quotes, and language oddities.',
},
'music-and-sound': {
title: 'Music & sound',
blurb: 'Music, audio, and sonic curiosities.',
},
'travel-and-places': {
title: 'Travel & places',
blurb: 'Hotels, restaurants, and places worth the trip.',
},
'food-and-drink': {
title: 'Food & drink',
blurb: 'Coffee, cooking, and edible delights.',
},
'home-and-kitchen': {
title: 'Home & kitchen',
blurb: 'Kitchenware, furniture, and household objects done right.',
},
'outdoors-and-nature': {
title: 'Outdoors & nature',
blurb: 'Gear and wonders for the world outside.',
},
'carry-and-wear': {
title: 'Carry & wear',
blurb: 'EDC, bags, knives, and clothing that lasts.',
},
'gadgets-and-toys': {
title: 'Gadgets & toys',
blurb: 'Hardware, novelties, games, and playthings.',
},
'learning-and-reference': {
title: 'Learning & reference',
blurb: 'Science, books, and reference rabbit holes.',
},
} as const;
export type Topic = keyof typeof TOPIC_META;
export const TOPICS = Object.keys(TOPIC_META) as [Topic, ...Topic[]];
export function isTopic(value: string): value is Topic {
return value in TOPIC_META;
}
+26
View File
@@ -1,10 +1,21 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getFeedCategories, getFeedTags } from '../lib/feed';
import { TOPIC_META, TOPICS } from '../lib/topics';
import { getTopicSlices } from '../lib/topic-items';
import { buildOgImage, buildBreadcrumbs, absoluteUrl } from '../lib/seo';
const categories = await getFeedCategories();
const tags = await getFeedTags();
const slices = await getTopicSlices();
const topics = TOPICS.map((topic) => {
const slice = slices.get(topic)!;
return {
topic,
title: TOPIC_META[topic].title,
count: slice.reviews.length + slice.finds.length,
};
}).sort((a, b) => b.count - a.count);
const description =
'Subscribe to Unique: RSS and JSON feeds for everything, plus per-category and per-tag feeds.';
@@ -51,6 +62,21 @@ const breadcrumbs = buildBreadcrumbs([
</li>
</ul>
<h2>By topic</h2>
<p class="hint">
The curated coarse slices — and the only feeds that include finds, not
just reviews/bundles/posts. See <a href="/topics/">all topics</a>.
</p>
<ul class="feed-list scopes">
{topics.map(({ topic, title, count }) => (
<li>
<a href={`/topics/${topic}/`}>{title}</a>
<span class="count">({count})</span>
<a class="rss" href={`/topics/${topic}/rss.xml`}>RSS</a>
</li>
))}
</ul>
<h2>By category</h2>
<p class="hint">Each category page also advertises its own feed for autodiscovery.</p>
<ul class="feed-list scopes">
+3
View File
@@ -155,6 +155,9 @@ const breadcrumbs = buildBreadcrumbs([
<dt>Via</dt>
<dd>
<a href={`/sources/#${sourceSlug(item.data.source)}`}>{item.data.source}</a>
<a class="source-more" href={`/sources/${sourceSlug(item.data.source)}/`}>
(more from this source)
</a>
</dd>
{item.data.tags.length > 0 && (
<>
+8 -1
View File
@@ -1,6 +1,7 @@
import type { APIContext } from 'astro';
import { getCollection } from 'astro:content';
import { FEED_DESCRIPTION } from '../lib/feed';
import { TOPIC_META, TOPICS } from '../lib/topics';
// llms.txt — https://llmstxt.org/ — a plain-Markdown map of the site for
// language models and other programmatic readers. Regenerated on every
@@ -49,7 +50,13 @@ Content lives in four collections: **reviews** (full write-ups), **bundles** (th
- [Everything (RSS 2.0)](${abs('/rss.xml')})
- [Everything (JSON Feed 1.1)](${abs('/feed.json')})
- Per-category RSS at /categories/<category>/rss.xml and per-tag RSS at /tags/<tag>/rss.xml — all listed at [/feeds/](${abs('/feeds/')})
- Per-topic RSS at /topics/<topic>/rss.xml (these include finds), per-category RSS at /categories/<category>/rss.xml, and per-tag RSS at /tags/<tag>/rss.xml — all listed at [/feeds/](${abs('/feeds/')})
## Topics
The curated, closed classification vocabulary. Each topic page lists its reviews and finds and has an RSS feed.
${TOPICS.map((t) => `- [${TOPIC_META[t].title}](${abs(`/topics/${t}/`)}): ${TOPIC_META[t].blurb}`).join('\n')}
## Machine-readable
+3
View File
@@ -154,6 +154,9 @@ const categoryLabel =
<a href={`/sources/#${sourceSlug(review.data.source)}`}>
{review.data.source}
</a>
<a class="source-more" href={`/sources/${sourceSlug(review.data.source)}/`}>
(more from this source)
</a>
</dd>
</>
)}
+7
View File
@@ -135,6 +135,9 @@ const breadcrumbs = buildBreadcrumbs([
</li>
))}
</ul>
<p class="all-link">
<a href={`/sources/${sourceSlug(source.name)}/`}>everything from this source →</a>
</p>
</div>
)}
</div>
@@ -237,6 +240,10 @@ const breadcrumbs = buildBreadcrumbs([
color: var(--muted);
}
.all-link {
margin: 0.5rem 0 0;
font-size: 0.78rem;
}
.recent {
margin-top: 0.7rem;
padding-top: 0.6rem;
+176
View File
@@ -0,0 +1,176 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import EntryList from '../../components/EntryList.astro';
import sourcesData from '../../../sources.json';
import { getCollection } from 'astro:content';
import { sourceSlug } from '../../lib/sources';
import {
buildOgImage,
buildBreadcrumbs,
buildItemList,
absoluteUrl,
} from '../../lib/seo';
export async function getStaticPaths() {
const [reviews, finds] = await Promise.all([
getCollection('reviews'),
getCollection('find'),
]);
const byDateDesc = <T extends { data: { date: Date } }>(a: T, b: T) =>
b.data.date.valueOf() - a.data.date.valueOf();
// Union of the catalog and every source named in content, so colophon
// links resolve even for retired sources.
const names = new Set<string>([
...sourcesData.sources.map((s: { name: string }) => s.name),
...finds.map((f) => f.data.source),
...reviews.flatMap((r) => (r.data.source ? [r.data.source] : [])),
]);
return [...names].map((name) => ({
params: { source: sourceSlug(name) },
props: {
name,
catalogEntry:
sourcesData.sources.find((s: { name: string }) => s.name === name) ??
null,
reviews: reviews.filter((r) => r.data.source === name).sort(byDateDesc),
finds: finds.filter((f) => f.data.source === name).sort(byDateDesc),
},
}));
}
const { name, catalogEntry, reviews, finds } = Astro.props;
const slug = sourceSlug(name);
const reviewRows = reviews.map((r) => ({
href: `/reviews/${r.id}/`,
date: r.data.date,
primary: r.data.name,
secondary: r.data.subtitle,
}));
const findRows = finds.map((f) => ({
href: `/find/${f.id}/`,
date: f.data.date,
primary: f.data.name,
secondary: f.data.subtitle ?? f.data.description,
}));
const count = reviewRows.length + findRows.length;
const lastUpdated = [reviews[0]?.data.date, finds[0]?.data.date]
.filter((d): d is Date => Boolean(d))
.sort((a, b) => b.valueOf() - a.valueOf())[0];
const parts = [
findRows.length > 0 &&
`${findRows.length} find${findRows.length === 1 ? '' : 's'}`,
reviewRows.length > 0 &&
`${reviewRows.length} review${reviewRows.length === 1 ? '' : 's'}`,
].filter(Boolean);
const description = `Everything surfaced via ${name} on Unique${
parts.length ? `: ${parts.join(' and ')}` : ''
}.`;
// Single-find sources make for thin pages; keep them browsable but out of
// the index until they accumulate content.
const noindex = count < 3;
const ogImage = buildOgImage(undefined, 'site', `${name} on Unique`);
const itemList = buildItemList(
[
...reviewRows.map((r) => ({ url: r.href, name: r.primary })),
...findRows.map((f) => ({ url: f.href, name: f.primary })),
],
`${name} on Unique`
);
const collectionPage: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
'@id': absoluteUrl(`/sources/${slug}/#collection`),
name: `${name} — Unique`,
description,
url: absoluteUrl(`/sources/${slug}/`),
inLanguage: 'en-US',
};
const breadcrumbs = buildBreadcrumbs([
{ name: 'Home', url: '/' },
{ name: 'Sources', url: '/sources/' },
{ name, url: `/sources/${slug}/` },
]);
function host(url: string) {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
---
<BaseLayout
title={`${name} (${count})`}
description={description}
subheader={`Everything via ${name}.`}
lastUpdated={lastUpdated}
ogImage={ogImage}
noindex={noindex}
jsonLd={[collectionPage, itemList, breadcrumbs]}
>
<header class="source-head">
<h1>{name}</h1>
{catalogEntry && (
<p class="source-meta">
<a href={catalogEntry.url} rel="noopener">{host(catalogEntry.url)}</a>
{catalogEntry.notes && <span class="notes"> — {catalogEntry.notes}</span>}
</p>
)}
</header>
{reviewRows.length > 0 && (
<section class="source-section">
<h2>Reviews</h2>
<EntryList entries={reviewRows} />
</section>
)}
{findRows.length > 0 && (
<section class="source-section">
<h2>Finds</h2>
<EntryList entries={findRows} />
</section>
)}
{count === 0 && <p class="empty">Nothing surfaced from this source yet.</p>}
<p class="back"><a href="/sources/">← all sources</a></p>
<style>
.source-head h1 {
margin-bottom: 0.15rem;
}
.source-meta {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.source-meta .notes {
font-style: italic;
}
.source-section {
margin-top: 2rem;
}
.source-section h2 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
}
.empty {
color: var(--muted);
font-style: italic;
}
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
+133
View File
@@ -0,0 +1,133 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import EntryGrid from '../../components/EntryGrid.astro';
import EntryList from '../../components/EntryList.astro';
import { TOPIC_META, TOPICS, type Topic } from '../../lib/topics';
import { getTopicSlices } from '../../lib/topic-items';
import type { Entry } from '../../lib/entries';
import {
buildOgImage,
buildBreadcrumbs,
buildItemList,
absoluteUrl,
} from '../../lib/seo';
export async function getStaticPaths() {
const slices = await getTopicSlices();
return TOPICS.map((topic) => ({
params: { topic },
props: { topic, slice: slices.get(topic)! },
}));
}
interface Props {
topic: Topic;
slice: Awaited<ReturnType<typeof getTopicSlices>> extends Map<Topic, infer S>
? S
: never;
}
const { topic, slice } = Astro.props;
const meta = TOPIC_META[topic];
const reviewEntries: Entry[] = slice.reviews.map((r) => ({
type: 'review',
id: r.id,
href: `/reviews/${r.id}/`,
date: r.data.date,
category: r.data.category,
tags: r.data.tags,
primary: r.data.name,
secondary: r.data.subtitle,
}));
const findRows = slice.finds.map((f) => ({
href: `/find/${f.id}/`,
date: f.data.date,
primary: f.data.name,
secondary: f.data.subtitle ?? f.data.description,
}));
const count = reviewEntries.length + findRows.length;
const lastUpdated = [slice.reviews[0]?.data.date, slice.finds[0]?.data.date]
.filter((d): d is Date => Boolean(d))
.sort((a, b) => b.valueOf() - a.valueOf())[0];
const description = `${meta.blurb} ${count} entries on Unique, feed included.`;
const feedHref = `/topics/${topic}/rss.xml`;
const ogImage = buildOgImage(undefined, 'site', `${meta.title} on Unique`);
const itemList = buildItemList(
[
...reviewEntries.map((e) => ({ url: e.href, name: e.primary })),
...slice.finds.map((f) => ({ url: `/find/${f.id}/`, name: f.data.name })),
],
`${meta.title} on Unique`
);
const collectionPage: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
'@id': absoluteUrl(`/topics/${topic}/#collection`),
name: `${meta.title} — Unique`,
description,
url: absoluteUrl(`/topics/${topic}/`),
inLanguage: 'en-US',
};
const breadcrumbs = buildBreadcrumbs([
{ name: 'Home', url: '/' },
{ name: 'Topics', url: '/topics/' },
{ name: meta.title, url: `/topics/${topic}/` },
]);
---
<BaseLayout
title={`${meta.title} (${count})`}
description={description}
subheader={meta.blurb}
lastUpdated={lastUpdated}
ogImage={ogImage}
jsonLd={[collectionPage, itemList, breadcrumbs]}
feeds={[{ title: `Unique — ${meta.title} (RSS)`, href: feedHref }]}
feedHref={feedHref}
wide
>
{reviewEntries.length > 0 && (
<section class="topic-reviews">
<h2>Reviews</h2>
<EntryGrid entries={reviewEntries} />
</section>
)}
{findRows.length > 0 && (
<section class="topic-finds">
<h2>Finds</h2>
<EntryList entries={findRows} />
</section>
)}
{count === 0 && <p class="empty">Nothing here yet — subscribe and be first to know.</p>}
<p class="back"><a href="/topics/">← all topics</a></p>
<style>
section h2 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
}
.topic-finds {
margin-top: 2.5rem;
}
.topic-finds:first-child {
margin-top: 0;
}
.empty {
color: var(--muted);
font-style: italic;
}
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
+18
View File
@@ -0,0 +1,18 @@
import { getTopicFeedItems, renderRss, FEED_TITLE } from '../../../lib/feed';
import { TOPIC_META, TOPICS } from '../../../lib/topics';
export async function getStaticPaths() {
return TOPICS.map((topic) => ({ params: { topic } }));
}
export async function GET(context) {
const { topic } = context.params;
const meta = TOPIC_META[topic];
return renderRss({
site: context.site,
path: `/topics/${topic}/rss.xml`,
title: `${FEED_TITLE}${meta.title}`,
description: `${meta.blurb} Reviews and finds on ${FEED_TITLE}.`,
items: await getTopicFeedItems(topic),
});
}
+130
View File
@@ -0,0 +1,130 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { TOPIC_META, TOPICS } from '../../lib/topics';
import { getTopicSlices } from '../../lib/topic-items';
import {
buildOgImage,
buildBreadcrumbs,
buildItemList,
absoluteUrl,
} from '../../lib/seo';
const slices = await getTopicSlices();
const rows = TOPICS.map((topic) => {
const slice = slices.get(topic)!;
return {
topic,
...TOPIC_META[topic],
reviews: slice.reviews.length,
finds: slice.finds.length,
};
}).sort((a, b) => b.reviews + b.finds - (a.reviews + a.finds));
const description =
'Topics on Unique: the curated, coarse slices of everything here — each one browsable and subscribable, finds included.';
const ogImage = buildOgImage(undefined, 'site', 'Topics on Unique');
const itemList = buildItemList(
rows.map((r) => ({ url: `/topics/${r.topic}/`, name: r.title })),
'Topics on Unique'
);
const collectionPage = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
'@id': absoluteUrl('/topics/#collection'),
name: 'Topics — Unique',
description,
url: absoluteUrl('/topics/'),
inLanguage: 'en-US',
};
const breadcrumbs = buildBreadcrumbs([
{ name: 'Home', url: '/' },
{ name: 'Topics', url: '/topics/' },
]);
---
<BaseLayout
title="Topics"
description={description}
subheader="Everything here, sliced coarsely."
ogImage={ogImage}
jsonLd={[collectionPage, itemList, breadcrumbs]}
>
<h1>Topics</h1>
<p>
A short, curated list of the slices this site covers. Unlike the
free-form <a href="/feeds/">tags</a>, topics are a closed set — and each
one has a feed that includes finds, so you can follow exactly the slice
you care about.
</p>
<ul class="topic-list">
{rows.map((r) => (
<li>
<div class="topic-head">
<a class="topic-title" href={`/topics/${r.topic}/`}>{r.title}</a>
<span class="count">
{r.reviews > 0 && `${r.reviews} review${r.reviews === 1 ? '' : 's'}`}
{r.reviews > 0 && r.finds > 0 && ' · '}
{r.finds > 0 && `${r.finds} find${r.finds === 1 ? '' : 's'}`}
</span>
<a class="rss" href={`/topics/${r.topic}/rss.xml`}>RSS</a>
</div>
<p class="blurb">{r.blurb}</p>
</li>
))}
</ul>
<style>
.topic-list {
list-style: none;
margin: 1.5rem 0 0;
padding: 0;
}
.topic-list li {
padding: 0.85rem 0;
border-bottom: 1px solid var(--rule);
}
.topic-head {
display: flex;
align-items: baseline;
gap: 0.6rem;
flex-wrap: wrap;
}
.topic-title {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.01em;
}
.count {
font-size: 0.8rem;
color: var(--muted);
}
.rss {
margin-left: auto;
padding: 0.05rem 0.5rem;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--rule);
color: var(--muted);
border-radius: 999px;
}
.rss:hover {
color: var(--accent);
text-decoration: none;
}
.blurb {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.95rem;
}
</style>
</BaseLayout>