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
+1
View File
@@ -77,6 +77,7 @@ name: "<from the find>"
subtitle: "<from the find — required for reviews>" subtitle: "<from the find — required for reviews>"
date: YYYY-MM-DD # today (the review date), not the find's date date: YYYY-MM-DD # today (the review date), not the find's date
category: <pick from existing categories or propose a new one> category: <pick from existing categories or propose a new one>
topics: <1-3 from the controlled vocabulary in src/lib/topics.ts (TOPIC_META keys) — usually the find's topics, adjusted if the review's angle differs; build fails on values outside the vocabulary>
tags: <merge the find's tags with any review-specific additions> tags: <merge the find's tags with any review-specific additions>
link: "<from the find — or a better canonical URL surfaced by step 3>" link: "<from the find — or a better canonical URL surfaced by step 3>"
linkText: "<from the find, if set>" linkText: "<from the find, if set>"
+1 -1
View File
@@ -57,7 +57,7 @@ For each extracted item, drop:
For every survivor, write one file at `src/content/find/<year>/<month>/<slug>/index.mdx` where `<year>` and `<month>` come from today's date. Hero image (when present) sits next to it as `src/content/find/<year>/<month>/<slug>/hero.<ext>`. The leaf folder name is the slug; URLs stay flat at `/find/<slug>/` because the content-collection loader uses a `leafSlug` id derivation. For every survivor, write one file at `src/content/find/<year>/<month>/<slug>/index.mdx` where `<year>` and `<month>` come from today's date. Hero image (when present) sits next to it as `src/content/find/<year>/<month>/<slug>/hero.<ext>`. The leaf folder name is the slug; URLs stay flat at `/find/<slug>/` because the content-collection loader uses a `leafSlug` id derivation.
- Slug: kebab-case form of the name (lowercase, ASCII, no leading numbers). - Slug: kebab-case form of the name (lowercase, ASCII, no leading numbers).
- Frontmatter: `name`, `subtitle` (optional, one-liner), `date` (today), `link` (external URL — required), `linkText` (optional), `source` (the source name from sources.json — required, exact match), `topics` (copied from the source's `topics`), `tags` (free editorial tags inferred from the item — keep to 2-4), `description` (optional meta override). - Frontmatter: `name`, `subtitle` (optional, one-liner), `date` (today), `link` (external URL — required), `linkText` (optional), `source` (the source name from sources.json — required, exact match), `topics` (1-3 values chosen from the controlled vocabulary in `src/lib/topics.ts` — the `TOPIC_META` keys; judge from the item itself, the source's `topics` in sources.json are hints only and may contain retired values; the build fails on anything outside the vocabulary; `[]` if nothing genuinely fits), `tags` (free editorial tags inferred from the item — keep to 2-4; add `giftable` when the item would make a genuinely good gift), `description` (optional meta override).
- Body: 2-3 sentence note. Concrete, name a *specific* virtue. Bad: "great design". Good: "the silicone bumper means it survives drops onto concrete; the lanyard hole is positioned where your thumb naturally rests". - Body: 2-3 sentence note. Concrete, name a *specific* virtue. Bad: "great design". Good: "the silicone bumper means it survives drops onto concrete; the lanyard hole is positioned where your thumb naturally rests".
- No daily cap — write every survivor. - No daily cap — write every survivor.
+2
View File
@@ -2,6 +2,8 @@
## July 2026 ## July 2026
- Topics — a new coarse classification axis for following slices of the site: an 18-value controlled vocabulary (`src/lib/topics.ts`), enum-enforced by the content schema on finds and reviews so unattended capture runs can't drift it. All 864 finds and 64 reviews retroactively classified (subagent batch job); the retired source-inherited values (`dad-jokes`, `quotes`, `apple-tips`, `general-delight`…) map into the new set, and `gifts` became a `giftable` tag on 140 finds ("giftable" is a facet, not a topic). New `/topics/` index and `/topics/<topic>/` pages where finds are first-class alongside reviews, per-topic RSS feeds that include finds (unlike category/tag feeds), a "By topic" section on `/feeds/`, a Topics footer link, and a Topics section in `llms.txt`; `daily-finds` and `build-review` skills now classify at capture/graduation time
- Per-source indexes: `/sources/<slug>/` lists everything surfaced via a source (reviews + finds, newest first, with the source's outbound link and notes); find and review colophons gain a "(more from this source)" link next to the source name, and `/sources/` cards link through via "everything from this source →". Pages exist for every source ever named in content, including retired ones; sub-3-item pages are noindexed
- Tag pages now include finds: `/tags/<tag>/` is generated from the union of entry and find tags (1,179 tags that existed only on finds — most tag links on find pages — previously 404'd) and lists tagged finds in a compact secondary section below the entry grid, keeping reviews/bundles/posts visually on top. Find-only tag pages with fewer than 3 finds are noindexed until they fill out; tag feeds stay entries-only, and the RSS pill only renders where a feed exists. Category pages are unaffected (finds have no category) - Tag pages now include finds: `/tags/<tag>/` is generated from the union of entry and find tags (1,179 tags that existed only on finds — most tag links on find pages — previously 404'd) and lists tagged finds in a compact secondary section below the entry grid, keeping reviews/bundles/posts visually on top. Find-only tag pages with fewer than 3 finds are noindexed until they fill out; tag feeds stay entries-only, and the RSS pill only renders where a feed exists. Category pages are unaffected (finds have no category)
- Feeds for everything: alongside the existing `/rss.xml`, add a JSON Feed 1.1 at `/feed.json`, per-category RSS at `/categories/<name>/rss.xml`, and per-tag RSS at `/tags/<name>/rss.xml` — all derived from a shared `src/lib/feed.ts` item source (reviews + posts + bundles, same visibility rules as the site). Category/tag pages advertise their scoped feed via `<link rel="alternate">` autodiscovery and show a visible RSS pill in the subheader (browsers dropped feed-detection UI years ago, so autodiscovery alone is invisible); a new `/feeds/` directory page (linked from the footer) lists every feed with entry counts; every RSS feed carries an `atom:link` self reference and renders as a readable page in browsers via `public/feed.xsl` - Feeds for everything: alongside the existing `/rss.xml`, add a JSON Feed 1.1 at `/feed.json`, per-category RSS at `/categories/<name>/rss.xml`, and per-tag RSS at `/tags/<name>/rss.xml` — all derived from a shared `src/lib/feed.ts` item source (reviews + posts + bundles, same visibility rules as the site). Category/tag pages advertise their scoped feed via `<link rel="alternate">` autodiscovery and show a visible RSS pill in the subheader (browsers dropped feed-detection UI years ago, so autodiscovery alone is invisible); a new `/feeds/` directory page (linked from the footer) lists every feed with entry counts; every RSS feed carries an `atom:link` self reference and renders as a readable page in browsers via `public/feed.xsl`
- Programmatic-reader pass: build-generated `/llms.txt` (llmstxt.org format — site description, feed/endpoint map, and every review, bundle, and post with one-line descriptions), `SearchAction` on the home-page `WebSite` JSON-LD wired to `/search/?q=`, and a "For robots" section on `/feeds/` documenting `search.json`, `llms.txt`, the sitemap, and JSON-LD coverage - Programmatic-reader pass: build-generated `/llms.txt` (llmstxt.org format — site description, feed/endpoint map, and every review, bundle, and post with one-line descriptions), `SearchAction` on the home-page `WebSite` JSON-LD wired to `/search/?q=`, and a "For robots" section on `/feeds/` documenting `search.json`, `llms.txt`, the sitemap, and JSON-LD coverage
+1
View File
@@ -19,6 +19,7 @@ Unattended runs never pause for confirmation — they publish, log to `daily-fin
- Find slugs are globally unique although folders are date-bucketed; URLs stay flat at `/find/<slug>/`. - Find slugs are globally unique although folders are date-bucketed; URLs stay flat at `/find/<slug>/`.
- Finds are reachable through bundles and through tag pages (a secondary "Finds" list under the entry grid on `/tags/<tag>/`) — an unbundled find is still invisible on the home feed, grids, and category pages. - Finds are reachable through bundles and through tag pages (a secondary "Finds" list under the entry grid on `/tags/<tag>/`) — an unbundled find is still invisible on the home feed, grids, and category pages.
- `source` frontmatter must exactly match a `name` in `sources.json`. - `source` frontmatter must exactly match a `name` in `sources.json`.
- `topics` frontmatter (finds and reviews) must come from the controlled vocabulary in `src/lib/topics.ts` (`TOPIC_META` keys) — the schema enum fails the build on anything else. Tags are free-form; topics are curated. "Giftable" is a tag (`giftable`), never a topic.
- Amazon links: store canonical `https://www.amazon.com/dp/<ASIN>` with no query params; `src/lib/affiliate.ts` adds the tracking ID, badge, and disclosure at render time. - Amazon links: store canonical `https://www.amazon.com/dp/<ASIN>` with no query params; `src/lib/affiliate.ts` adds the tracking ID, badge, and disclosure at render time.
- Commit style: see `git log``content: …` for capture runs, `site: …` for everything user-visible; one commit per pipeline stage. - Commit style: see `git log``content: …` for capture runs, `site: …` for everything user-visible; one commit per pipeline stage.
- `npm run build` (astro check + build) must pass before pushing. - `npm run build` (astro check + build) must pass before pushing.
+2
View File
@@ -17,6 +17,8 @@ Built with [Astro](https://astro.build) + MDX.
- **Unified chronological feed** at `/index3/` across reviews + bundles + posts - **Unified chronological feed** at `/index3/` across reviews + bundles + posts
- **SEO-friendly site footer** with Browse / Collections (live counts) / About link columns and a centered legal line; consistent 76rem page width across the entire site (article prose constrained to a comfortable reading measure inside the wider layout) - **SEO-friendly site footer** with Browse / Collections (live counts) / About link columns and a centered legal line; consistent 76rem page width across the entire site (article prose constrained to a comfortable reading measure inside the wider layout)
- Reviews render as two-line index entries: bold name + muted subtitle (`Cotypist` / *Autocomplete that lives everywhere on your Mac*) - Reviews render as two-line index entries: bold name + muted subtitle (`Cotypist` / *Autocomplete that lives everywhere on your Mac*)
- **Topics — the curated subscription axis**: an 18-value controlled vocabulary in `src/lib/topics.ts` (schema-enum-enforced on finds and reviews, so the unattended pipeline can't drift it); `/topics/` index + `/topics/<t>/` pages where finds are first-class alongside reviews, and per-topic RSS feeds that include finds — unlike category/tag feeds. Facets (`open-source`, `bifl`, `giftable`…) stay tags by design
- **Per-source indexes**: `/sources/<slug>/` lists every review and find surfaced via a source (generated for the catalog all source names in content); find/review colophons link "(more from this source)" next to the source name, and `/sources/` cards link through; thin pages (<3 items) noindexed
- Auto-generated `/categories/<name>/` and `/tags/<name>/` listing pages; tag pages are built from the union of entry and find tags (so every tag linked from a find resolves) and list tagged finds in a secondary section — find-only tag pages with fewer than 3 finds are noindexed until they accumulate content; category pages stay entries-only (finds have no category) - Auto-generated `/categories/<name>/` and `/tags/<name>/` listing pages; tag pages are built from the union of entry and find tags (so every tag linked from a find resolves) and list tagged finds in a secondary section — find-only tag pages with fewer than 3 finds are noindexed until they accumulate content; category pages stay entries-only (finds have no category)
- **Amazon affiliate handling** in `src/lib/affiliate.ts`: outbound links to Amazon (`amazon.com`, `amzn.to`, `a.co`, `smile.amazon.com`) get the configured tracking ID appended automatically; the link gets `rel="sponsored"`, an "Affiliate" badge appears next to it, and a one-line disclosure is shown beneath. Finds and reviews also take an optional `amazonLink` frontmatter field (canonical `/dp/<ASIN>` form) rendered as an "Also on Amazon" link — the capture pipeline populates it when a shortlisted physical product has an exact Amazon match. Add additional marketplace IDs (e.g. `amazon.co.uk`) to the `AMAZON_TAGS` map when needed - **Amazon affiliate handling** in `src/lib/affiliate.ts`: outbound links to Amazon (`amazon.com`, `amzn.to`, `a.co`, `smile.amazon.com`) get the configured tracking ID appended automatically; the link gets `rel="sponsored"`, an "Affiliate" badge appears next to it, and a one-line disclosure is shown beneath. Finds and reviews also take an optional `amazonLink` frontmatter field (canonical `/dp/<ASIN>` form) rendered as an "Also on Amazon" link — the capture pipeline populates it when a shortlisted physical product has an exact Amazon match. Add additional marketplace IDs (e.g. `amazon.co.uk`) to the `AMAZON_TAGS` map when needed
- **SEO surface**: single `<SEO>` component owning canonical, Open Graph, Twitter Card, feed autodiscovery, and JSON-LD; per-page structured data (`Review`, `BlogPosting`, `Product`, `Article`, `CollectionPage`, `ItemList`, `BreadcrumbList`, `WebSite` with `SearchAction`, `Organization`); auto-generated `sitemap-index.xml` (excludes `/grid/` and `/index3/`); `robots.txt`; static OG image fallbacks under `/public/og/`; promoted finds canonical to their review - **SEO surface**: single `<SEO>` component owning canonical, Open Graph, Twitter Card, feed autodiscovery, and JSON-LD; per-page structured data (`Review`, `BlogPosting`, `Product`, `Article`, `CollectionPage`, `ItemList`, `BreadcrumbList`, `WebSite` with `SearchAction`, `Organization`); auto-generated `sitemap-index.xml` (excludes `/grid/` and `/index3/`); `robots.txt`; static OG image fallbacks under `/public/og/`; promoted finds canonical to their review
+1 -1
View File
@@ -137,7 +137,7 @@ const feedSlashShim = {
apply: 'serve', apply: 'serve',
configureServer(server) { configureServer(server) {
server.middlewares.use((req, _res, next) => { server.middlewares.use((req, _res, next) => {
if (/^\/(tags|categories)\/[^/]+\/rss\.xml$/.test(req.url)) req.url += '/'; if (/^\/(tags|categories|topics)\/[^/]+\/rss\.xml$/.test(req.url)) req.url += '/';
next(); next();
}); });
}, },
+3 -1
View File
@@ -1,5 +1,6 @@
import { defineCollection, z } from 'astro:content'; import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders'; import { glob } from 'astro/loaders';
import { TOPICS } from './lib/topics';
const stripIndex = ({ entry }: { entry: string }) => const stripIndex = ({ entry }: { entry: string }) =>
entry.replace(/\/index\.mdx$/, ''); entry.replace(/\/index\.mdx$/, '');
@@ -18,6 +19,7 @@ const reviews = defineCollection({
subtitle: z.string(), subtitle: z.string(),
date: z.coerce.date(), date: z.coerce.date(),
category: z.string(), category: z.string(),
topics: z.array(z.enum(TOPICS)).default([]),
tags: z.array(z.string()).default([]), tags: z.array(z.string()).default([]),
link: z.string().url().optional(), link: z.string().url().optional(),
linkText: z.string().optional(), linkText: z.string().optional(),
@@ -57,7 +59,7 @@ const find = defineCollection({
linkText: z.string().optional(), linkText: z.string().optional(),
amazonLink: z.string().url().optional(), amazonLink: z.string().url().optional(),
source: z.string(), source: z.string(),
topics: z.array(z.string()).default([]), topics: z.array(z.enum(TOPICS)).default([]),
tags: z.array(z.string()).default([]), tags: z.array(z.string()).default([]),
description: z.string().optional(), description: z.string().optional(),
promotedTo: 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> <ul>
<li><a href="/">Home</a></li> <li><a href="/">Home</a></li>
<li><a href="/grid/">Grid</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="/sources/">Sources</a></li>
<li><a href="/feeds/">Feeds</a></li> <li><a href="/feeds/">Feeds</a></li>
</ul> </ul>
@@ -520,6 +521,12 @@ const year = new Date().getFullYear();
border-bottom-color: currentColor; border-bottom-color: currentColor;
text-decoration: none; text-decoration: none;
} }
.colophon .source-more {
margin-left: 0.45rem;
font-size: 0.72rem;
border-bottom: 0;
font-style: italic;
}
.colophon .colophon-tags { .colophon .colophon-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+42 -10
View File
@@ -1,5 +1,6 @@
import rss from '@astrojs/rss'; 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 * One source of truth for everything syndicated: the main /rss.xml and
@@ -28,6 +29,28 @@ export function getFeedItems(): Promise<FeedItem[]> {
return itemsCache; 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[]> { async function buildFeedItems(): Promise<FeedItem[]> {
const [reviews, posts, bundles] = await Promise.all([ const [reviews, posts, bundles] = await Promise.all([
getCollection('reviews'), getCollection('reviews'),
@@ -36,14 +59,7 @@ async function buildFeedItems(): Promise<FeedItem[]> {
]); ]);
const items: FeedItem[] = [ const items: FeedItem[] = [
...reviews.map((r) => ({ ...reviews.map(reviewToFeedItem),
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,
})),
...posts.map((p) => ({ ...posts.map((p) => ({
title: p.data.title, title: p.data.title,
description: p.data.description ?? '', description: p.data.description ?? '',
@@ -65,6 +81,22 @@ async function buildFeedItems(): Promise<FeedItem[]> {
return items.sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf()); 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. */ /** Shape @astrojs/rss expects. */
export function toRssItems(items: FeedItem[]) { export function toRssItems(items: FeedItem[]) {
return items.map((i) => ({ return items.map((i) => ({
@@ -72,7 +104,7 @@ export function toRssItems(items: FeedItem[]) {
description: i.description, description: i.description,
pubDate: i.pubDate, pubDate: i.pubDate,
link: i.link, 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 BaseLayout from '../layouts/BaseLayout.astro';
import { getFeedCategories, getFeedTags } from '../lib/feed'; 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'; import { buildOgImage, buildBreadcrumbs, absoluteUrl } from '../lib/seo';
const categories = await getFeedCategories(); const categories = await getFeedCategories();
const tags = await getFeedTags(); 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 = const description =
'Subscribe to Unique: RSS and JSON feeds for everything, plus per-category and per-tag feeds.'; 'Subscribe to Unique: RSS and JSON feeds for everything, plus per-category and per-tag feeds.';
@@ -51,6 +62,21 @@ const breadcrumbs = buildBreadcrumbs([
</li> </li>
</ul> </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> <h2>By category</h2>
<p class="hint">Each category page also advertises its own feed for autodiscovery.</p> <p class="hint">Each category page also advertises its own feed for autodiscovery.</p>
<ul class="feed-list scopes"> <ul class="feed-list scopes">
+3
View File
@@ -155,6 +155,9 @@ const breadcrumbs = buildBreadcrumbs([
<dt>Via</dt> <dt>Via</dt>
<dd> <dd>
<a href={`/sources/#${sourceSlug(item.data.source)}`}>{item.data.source}</a> <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> </dd>
{item.data.tags.length > 0 && ( {item.data.tags.length > 0 && (
<> <>
+8 -1
View File
@@ -1,6 +1,7 @@
import type { APIContext } from 'astro'; import type { APIContext } from 'astro';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { FEED_DESCRIPTION } from '../lib/feed'; 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 // llms.txt — https://llmstxt.org/ — a plain-Markdown map of the site for
// language models and other programmatic readers. Regenerated on every // 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 (RSS 2.0)](${abs('/rss.xml')})
- [Everything (JSON Feed 1.1)](${abs('/feed.json')}) - [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 ## Machine-readable
+3
View File
@@ -154,6 +154,9 @@ const categoryLabel =
<a href={`/sources/#${sourceSlug(review.data.source)}`}> <a href={`/sources/#${sourceSlug(review.data.source)}`}>
{review.data.source} {review.data.source}
</a> </a>
<a class="source-more" href={`/sources/${sourceSlug(review.data.source)}/`}>
(more from this source)
</a>
</dd> </dd>
</> </>
)} )}
+7
View File
@@ -135,6 +135,9 @@ const breadcrumbs = buildBreadcrumbs([
</li> </li>
))} ))}
</ul> </ul>
<p class="all-link">
<a href={`/sources/${sourceSlug(source.name)}/`}>everything from this source →</a>
</p>
</div> </div>
)} )}
</div> </div>
@@ -237,6 +240,10 @@ const breadcrumbs = buildBreadcrumbs([
color: var(--muted); color: var(--muted);
} }
.all-link {
margin: 0.5rem 0 0;
font-size: 0.78rem;
}
.recent { .recent {
margin-top: 0.7rem; margin-top: 0.7rem;
padding-top: 0.6rem; 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>