diff --git a/.claude/skills/build-review/SKILL.md b/.claude/skills/build-review/SKILL.md index 2ca8ff2..27dff74 100644 --- a/.claude/skills/build-review/SKILL.md +++ b/.claude/skills/build-review/SKILL.md @@ -77,6 +77,7 @@ name: "" subtitle: "" date: YYYY-MM-DD # today (the review date), not the find's date category: +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: link: "" linkText: "" diff --git a/.claude/skills/daily-finds/SKILL.md b/.claude/skills/daily-finds/SKILL.md index f2b6f5d..27726ab 100644 --- a/.claude/skills/daily-finds/SKILL.md +++ b/.claude/skills/daily-finds/SKILL.md @@ -57,7 +57,7 @@ For each extracted item, drop: For every survivor, write one file at `src/content/find////index.mdx` where `` and `` come from today's date. Hero image (when present) sits next to it as `src/content/find////hero.`. The leaf folder name is the slug; URLs stay flat at `/find//` because the content-collection loader uses a `leafSlug` id derivation. - 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". - No daily cap — write every survivor. diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ab3a9..7eeea2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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//` 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//` 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//` 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//rss.xml`, and per-tag RSS at `/tags//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 `` 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 diff --git a/CLAUDE.md b/CLAUDE.md index 28ac86f..531ec27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`. - Finds are reachable through bundles and through tag pages (a secondary "Finds" list under the entry grid on `/tags//`) — an unbundled find is still invisible on the home feed, grids, and category pages. - `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/` 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. - `npm run build` (astro check + build) must pass before pushing. diff --git a/README.md b/README.md index 146df3a..59b5223 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Built with [Astro](https://astro.build) + MDX. - **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) - 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//` 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//` 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//` and `/tags//` 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/` 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 `` 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 diff --git a/astro.config.mjs b/astro.config.mjs index dce08a6..7c57144 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -137,7 +137,7 @@ const feedSlashShim = { apply: 'serve', configureServer(server) { 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(); }); }, diff --git a/src/content.config.ts b/src/content.config.ts index e246d4b..6815777 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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(), diff --git a/src/content/reviews/folo/hero.png b/src/content/reviews/folo/hero.png new file mode 100644 index 0000000..86f2926 Binary files /dev/null and b/src/content/reviews/folo/hero.png differ diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 0c7f427..f63e5c0 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -134,6 +134,7 @@ const year = new Date().getFullYear(); @@ -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; diff --git a/src/lib/feed.ts b/src/lib/feed.ts index 46a168b..1c77569 100644 --- a/src/lib/feed.ts +++ b/src/lib/feed.ts @@ -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 { 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 { const [reviews, posts, bundles] = await Promise.all([ getCollection('reviews'), @@ -36,14 +59,7 @@ async function buildFeedItems(): Promise { ]); 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 { 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 { + 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), })); } diff --git a/src/lib/topic-items.ts b/src/lib/topic-items.ts new file mode 100644 index 0000000..4e66751 --- /dev/null +++ b/src/lib/topic-items.ts @@ -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> | null = null; + +/** Every topic's reviews and finds, each sorted newest-first. */ +export function getTopicSlices(): Promise> { + if (!cache) cache = buildSlices(); + return cache; +} + +async function buildSlices(): Promise> { + const [reviews, finds] = await Promise.all([ + getCollection('reviews'), + getCollection('find'), + ]); + const map = new Map( + 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; +} diff --git a/src/lib/topics.ts b/src/lib/topics.ts new file mode 100644 index 0000000..a579aac --- /dev/null +++ b/src/lib/topics.ts @@ -0,0 +1,94 @@ +/** + * The topics vocabulary — the coarse, curated subscription axis. + * + * Topics are the closed set of slices someone can follow (/topics// 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; +} diff --git a/src/pages/feeds.astro b/src/pages/feeds.astro index f8e5db6..2087493 100644 --- a/src/pages/feeds.astro +++ b/src/pages/feeds.astro @@ -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([ +

By topic

+

+ The curated coarse slices — and the only feeds that include finds, not + just reviews/bundles/posts. See all topics. +

+
    + {topics.map(({ topic, title, count }) => ( +
  • + {title} + ({count}) + RSS +
  • + ))} +
+

By category

Each category page also advertises its own feed for autodiscovery.

    diff --git a/src/pages/find/[...slug].astro b/src/pages/find/[...slug].astro index 8f338eb..84cca04 100644 --- a/src/pages/find/[...slug].astro +++ b/src/pages/find/[...slug].astro @@ -155,6 +155,9 @@ const breadcrumbs = buildBreadcrumbs([
    Via
    {item.data.source} + + (more from this source) +
    {item.data.tags.length > 0 && ( <> diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts index 4db360e..db2b3ab 100644 --- a/src/pages/llms.txt.ts +++ b/src/pages/llms.txt.ts @@ -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//rss.xml and per-tag RSS at /tags//rss.xml — all listed at [/feeds/](${abs('/feeds/')}) +- Per-topic RSS at /topics//rss.xml (these include finds), per-category RSS at /categories//rss.xml, and per-tag RSS at /tags//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 diff --git a/src/pages/reviews/[...slug].astro b/src/pages/reviews/[...slug].astro index d6a45fc..c0b4773 100644 --- a/src/pages/reviews/[...slug].astro +++ b/src/pages/reviews/[...slug].astro @@ -154,6 +154,9 @@ const categoryLabel = {review.data.source} + + (more from this source) + )} diff --git a/src/pages/sources.astro b/src/pages/sources.astro index bda29fd..566a8e1 100644 --- a/src/pages/sources.astro +++ b/src/pages/sources.astro @@ -135,6 +135,9 @@ const breadcrumbs = buildBreadcrumbs([ ))}
+ )} @@ -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; diff --git a/src/pages/sources/[source].astro b/src/pages/sources/[source].astro new file mode 100644 index 0000000..b651bde --- /dev/null +++ b/src/pages/sources/[source].astro @@ -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 = (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([ + ...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 = { + '@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; + } +} +--- + + +
+

{name}

+ {catalogEntry && ( +

+ {host(catalogEntry.url)} + {catalogEntry.notes && — {catalogEntry.notes}} +

+ )} +
+ + {reviewRows.length > 0 && ( +
+

Reviews

+ +
+ )} + + {findRows.length > 0 && ( +
+

Finds

+ +
+ )} + + {count === 0 &&

Nothing surfaced from this source yet.

} + +

← all sources

+ + +
diff --git a/src/pages/topics/[topic].astro b/src/pages/topics/[topic].astro new file mode 100644 index 0000000..4e6111f --- /dev/null +++ b/src/pages/topics/[topic].astro @@ -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> extends Map + ? 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 = { + '@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}/` }, +]); +--- + + + {reviewEntries.length > 0 && ( +
+

Reviews

+ +
+ )} + + {findRows.length > 0 && ( +
+

Finds

+ +
+ )} + + {count === 0 &&

Nothing here yet — subscribe and be first to know.

} + +

← all topics

+ + +
diff --git a/src/pages/topics/[topic]/rss.xml.js b/src/pages/topics/[topic]/rss.xml.js new file mode 100644 index 0000000..d8d13ed --- /dev/null +++ b/src/pages/topics/[topic]/rss.xml.js @@ -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), + }); +} diff --git a/src/pages/topics/index.astro b/src/pages/topics/index.astro new file mode 100644 index 0000000..1f741a2 --- /dev/null +++ b/src/pages/topics/index.astro @@ -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/' }, +]); +--- + + +

Topics

+

+ A short, curated list of the slices this site covers. Unlike the + free-form tags, topics are a closed set — and each + one has a feed that includes finds, so you can follow exactly the slice + you care about. +

+ +
    + {rows.map((r) => ( +
  • +
    + {r.title} + + {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'}`} + + RSS +
    +

    {r.blurb}

    +
  • + ))} +
+ + +