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:
@@ -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 |
@@ -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
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user