site: feeds everywhere + programmatic surface

Feeds: JSON Feed 1.1 at /feed.json, per-category and per-tag RSS at
/categories/<name>/rss.xml and /tags/<name>/rss.xml alongside the main
/rss.xml — all from a shared src/lib/feed.ts item source. Category/tag
pages advertise their scoped feed via autodiscovery; new /feeds/
directory page (footer-linked) lists every feed with counts; RSS feeds
gain an atom:link self reference and a feed.xsl stylesheet so they
render readably in browsers.

Programmatic readers: build-generated /llms.txt (site map with every
review/bundle/post), SearchAction on the 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.

Claude-Session: https://claude.ai/code/session_01WZaczDJjL3xZ3u5spsN5AL
This commit is contained in:
2026-07-12 17:56:55 -04:00
parent d91ebacf85
commit 86ea4c8873
15 changed files with 536 additions and 39 deletions
+7
View File
@@ -19,6 +19,8 @@ interface Props {
modifiedTime?: Date;
articleTags?: string[];
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
/** Page-scoped feeds (category/tag) advertised alongside the site-wide ones. */
feeds?: Array<{ title: string; href: string }>;
}
const {
@@ -33,6 +35,7 @@ const {
modifiedTime,
articleTags = [],
jsonLd,
feeds = [],
} = Astro.props;
const SITE_NAME = 'Unique';
@@ -59,6 +62,10 @@ const jsonLdBlocks = jsonLd
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#fafaf7" />
<link rel="alternate" type="application/rss+xml" title={`${SITE_NAME} — RSS`} href="/rss.xml" />
<link rel="alternate" type="application/feed+json" title={`${SITE_NAME} — JSON Feed`} href="/feed.json" />
{feeds.map((f) => (
<link rel="alternate" type="application/rss+xml" title={f.title} href={f.href} />
))}
<meta property="og:type" content={ogType} />
<meta property="og:title" content={title} />
+5
View File
@@ -20,6 +20,8 @@ interface Props {
modifiedTime?: Date;
articleTags?: string[];
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
/** Page-scoped feeds (category/tag) advertised alongside the site-wide ones. */
feeds?: Array<{ title: string; href: string }>;
/** Opt into the wider content tier on roomy viewports (grid/listing pages). */
wide?: boolean;
}
@@ -40,6 +42,7 @@ const {
modifiedTime,
articleTags,
jsonLd,
feeds,
wide,
} = Astro.props;
const siteName = 'Unique';
@@ -77,6 +80,7 @@ const year = new Date().getFullYear();
modifiedTime={modifiedTime}
articleTags={articleTags}
jsonLd={jsonLd}
feeds={feeds}
/>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "fc978456945c49ae90a0b7e0b891ffd2"}'></script>
</head>
@@ -123,6 +127,7 @@ const year = new Date().getFullYear();
<li><a href="/">Home</a></li>
<li><a href="/grid/">Grid</a></li>
<li><a href="/sources/">Sources</a></li>
<li><a href="/feeds/">Feeds</a></li>
</ul>
</section>
<section>
+130
View File
@@ -0,0 +1,130 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
/**
* One source of truth for everything syndicated: the main /rss.xml and
* /feed.json feeds plus every per-category and per-tag feed derive their
* items from here. Mirrors getAllEntries() (reviews + posts + bundles —
* finds stay bundle-only) but keeps the description field feeds need.
*/
export type FeedItem = {
title: string;
description: string;
pubDate: Date;
/** Site-relative path, e.g. /reviews/foo/ */
link: string;
category: string;
tags: string[];
};
export const FEED_TITLE = 'Unique';
export const FEED_DESCRIPTION =
'A small log of delightful, unique things — products, apps, phenomena, oddities.';
let itemsCache: Promise<FeedItem[]> | null = null;
export function getFeedItems(): Promise<FeedItem[]> {
if (!itemsCache) itemsCache = buildFeedItems();
return itemsCache;
}
async function buildFeedItems(): Promise<FeedItem[]> {
const [reviews, posts, bundles] = await Promise.all([
getCollection('reviews'),
getCollection('posts'),
getCollection('bundles'),
]);
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,
})),
...posts.map((p) => ({
title: p.data.title,
description: p.data.description ?? '',
pubDate: p.data.date,
link: `/posts/${p.id}/`,
category: p.data.category,
tags: p.data.tags,
})),
...bundles.map((b) => ({
title: b.data.title,
description: b.data.description ?? b.data.blurb ?? '',
pubDate: b.data.date,
link: `/bundles/${b.id}/`,
category: b.data.category,
tags: b.data.tags,
})),
];
return items.sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
}
/** Shape @astrojs/rss expects. */
export function toRssItems(items: FeedItem[]) {
return items.map((i) => ({
title: i.title,
description: i.description,
pubDate: i.pubDate,
link: i.link,
categories: [i.category, ...i.tags],
}));
}
/**
* Render an RSS 2.0 response with the trimmings every feed on the site
* shares: language, an atom:link self reference, and the browser-facing
* XSL stylesheet.
*/
export function renderRss(opts: {
site: URL;
/** Site-relative path of the feed itself, e.g. /rss.xml */
path: string;
title: string;
description: string;
items: FeedItem[];
}) {
const selfUrl = new URL(opts.path, opts.site).href;
return rss({
title: opts.title,
description: opts.description,
site: opts.site,
items: toRssItems(opts.items),
stylesheet: '/feed.xsl',
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
customData: [
'<language>en-us</language>',
`<atom:link href="${selfUrl}" rel="self" type="application/rss+xml"/>`,
].join(''),
});
}
export type FeedScope = { name: string; count: number };
export async function getFeedCategories(): Promise<FeedScope[]> {
return scopeCounts((i) => [i.category]);
}
export async function getFeedTags(): Promise<FeedScope[]> {
return scopeCounts((i) => i.tags);
}
async function scopeCounts(
pick: (i: FeedItem) => string[]
): Promise<FeedScope[]> {
const items = await getFeedItems();
const counts = new Map<string, number>();
for (const item of items) {
for (const name of pick(item)) {
counts.set(name, (counts.get(name) ?? 0) + 1);
}
}
return [...counts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
}
+8
View File
@@ -106,6 +106,14 @@ export const WEBSITE = {
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
publisher: { '@id': `${SITE_URL}/#org` },
inLanguage: 'en-US',
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${SITE_URL}/search/?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
};
const SOFTWARE_CATEGORIES = new Set([
+1
View File
@@ -60,6 +60,7 @@ const breadcrumbs = buildBreadcrumbs([
lastUpdated={lastUpdated}
ogImage={ogImage}
jsonLd={[collectionPage, itemList, breadcrumbs]}
feeds={[{ title: `Unique — ${category} (RSS)`, href: `/categories/${category}/rss.xml` }]}
wide
>
<EntryGrid entries={entries} />
@@ -0,0 +1,18 @@
import { getFeedItems, getFeedCategories, renderRss, FEED_TITLE } from '../../../lib/feed';
export async function getStaticPaths() {
const categories = await getFeedCategories();
return categories.map(({ name }) => ({ params: { category: name } }));
}
export async function GET(context) {
const { category } = context.params;
const items = (await getFeedItems()).filter((i) => i.category === category);
return renderRss({
site: context.site,
path: `/categories/${category}/rss.xml`,
title: `${FEED_TITLE}${category}`,
description: `Entries in ${category} on ${FEED_TITLE}.`,
items,
});
}
+28
View File
@@ -0,0 +1,28 @@
import { getFeedItems, FEED_TITLE, FEED_DESCRIPTION } from '../lib/feed';
// JSON Feed 1.1 — https://jsonfeed.org/version/1.1
export async function GET(context) {
const items = await getFeedItems();
const feed = {
version: 'https://jsonfeed.org/version/1.1',
title: FEED_TITLE,
home_page_url: context.site.href,
feed_url: new URL('/feed.json', context.site).href,
description: FEED_DESCRIPTION,
language: 'en-US',
authors: [{ name: 'rzen', url: new URL('/about/', context.site).href }],
items: items.map((i) => ({
id: new URL(i.link, context.site).href,
url: new URL(i.link, context.site).href,
title: i.title,
content_text: i.description,
date_published: i.pubDate.toISOString(),
tags: [i.category, ...i.tags],
})),
};
return new Response(JSON.stringify(feed, null, 2), {
headers: { 'Content-Type': 'application/feed+json' },
});
}
+149
View File
@@ -0,0 +1,149 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { getFeedCategories, getFeedTags } from '../lib/feed';
import { buildOgImage, buildBreadcrumbs, absoluteUrl } from '../lib/seo';
const categories = await getFeedCategories();
const tags = await getFeedTags();
const description =
'Subscribe to Unique: RSS and JSON feeds for everything, plus per-category and per-tag feeds.';
const ogImage = buildOgImage(undefined, 'site', 'Feeds on Unique');
const collectionPage = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
'@id': absoluteUrl('/feeds/#collection'),
name: 'Feeds — Unique',
description,
url: absoluteUrl('/feeds/'),
inLanguage: 'en-US',
};
const breadcrumbs = buildBreadcrumbs([
{ name: 'Home', url: '/' },
{ name: 'Feeds', url: '/feeds/' },
]);
---
<BaseLayout
title="Feeds"
description={description}
subheader="Take Unique with you."
jsonLd={[collectionPage, breadcrumbs]}
>
<h1>Feeds</h1>
<p>
Everything published here — reviews, bundles, posts — is available as a
feed. Paste any of these into your feed reader.
</p>
<h2>Everything</h2>
<ul class="feed-list">
<li>
<a href="/rss.xml">/rss.xml</a> — all entries, RSS 2.0
</li>
<li>
<a href="/feed.json">/feed.json</a> — all entries,
<a href="https://jsonfeed.org/">JSON Feed 1.1</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">
{categories.map(({ name, count }) => (
<li>
<a href={`/categories/${name}/`}>{name}</a>
<span class="count">({count})</span>
<a class="rss" href={`/categories/${name}/rss.xml`}>RSS</a>
</li>
))}
</ul>
<h2>By tag</h2>
<ul class="feed-list scopes">
{tags.map(({ name, count }) => (
<li>
<a href={`/tags/${name}/`}>#{name}</a>
<span class="count">({count})</span>
<a class="rss" href={`/tags/${name}/rss.xml`}>RSS</a>
</li>
))}
</ul>
<h2>For robots</h2>
<p>Reading the site programmatically? You may also want:</p>
<ul class="feed-list">
<li>
<a href="/search.json">/search.json</a> — the full index of everything on
the site as JSON, including individual finds (which don't appear in the
feeds — they surface through bundles)
</li>
<li>
<a href="/llms.txt">/llms.txt</a> — a plain-text map of the site for
language models and other tools
</li>
<li>
<a href="/sitemap-index.xml">/sitemap-index.xml</a> — every page, for crawlers
</li>
<li>
Every page embeds schema.org JSON-LD (<code>Review</code>,
<code>Product</code>, <code>Article</code>, <code>CollectionPage</code>…)
</li>
</ul>
<style>
h2 {
margin-top: 2.25rem;
font-size: 1.15rem;
}
.hint {
margin: 0.25rem 0 0.75rem;
font-size: 0.85rem;
color: var(--muted);
font-style: italic;
}
.feed-list {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
}
.feed-list li {
margin: 0.3rem 0;
}
.feed-list.scopes {
list-style: none;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1.4rem;
}
.feed-list.scopes li {
margin: 0;
white-space: nowrap;
}
.count {
margin-left: 0.25rem;
font-size: 0.8rem;
color: var(--muted);
}
.rss {
margin-left: 0.45rem;
padding: 0.05rem 0.4rem;
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;
vertical-align: middle;
}
.rss:hover {
color: var(--accent);
text-decoration: none;
}
</style>
</BaseLayout>
+81
View File
@@ -0,0 +1,81 @@
import type { APIContext } from 'astro';
import { getCollection } from 'astro:content';
import { FEED_DESCRIPTION } from '../lib/feed';
// llms.txt — https://llmstxt.org/ — a plain-Markdown map of the site for
// language models and other programmatic readers. Regenerated on every
// build from the content collections, so it never goes stale.
export async function GET(context: APIContext) {
const site = context.site!;
const abs = (path: string) => new URL(path, site).href;
const [reviews, bundles, posts, finds] = await Promise.all([
getCollection('reviews'),
getCollection('bundles'),
getCollection('posts'),
getCollection('find'),
]);
const byDateDesc = <T extends { data: { date: Date } }>(a: T, b: T) =>
b.data.date.valueOf() - a.data.date.valueOf();
const reviewLines = reviews
.sort(byDateDesc)
.map(
(r) =>
`- [${r.data.name}](${abs(`/reviews/${r.id}/`)}): ${r.data.description ?? r.data.subtitle}`
);
const bundleLines = bundles
.sort(byDateDesc)
.map(
(b) =>
`- [${b.data.title}](${abs(`/bundles/${b.id}/`)}): ${b.data.description ?? b.data.blurb ?? `${b.data.items.length} finds`}`
);
const postLines = posts
.sort(byDateDesc)
.map(
(p) => `- [${p.data.title}](${abs(`/posts/${p.id}/`)}): ${p.data.description ?? ''}`
);
const body = `# Unique
> ${FEED_DESCRIPTION} One person's editorial log, published daily at ${site.href} — every entry is something genuinely delightful, hand-picked and written up.
Content lives in four collections: **reviews** (full write-ups), **bundles** (themed roundups), **posts** (essays), and **finds** (short captures — ${finds.length} so far — that surface only through the bundles referencing them, at /find/<slug>/).
## Feeds
- [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/')})
## Machine-readable
- [Full site index as JSON](${abs('/search.json')}): every review, bundle, post, and find with title, URL, date, tags, and blurb
- [Sitemap](${abs('/sitemap-index.xml')})
- Every page embeds schema.org JSON-LD (Review, Product, SoftwareApplication, Article, CollectionPage, BreadcrumbList)
## Reviews
${reviewLines.join('\n')}
## Bundles
${bundleLines.join('\n')}
## Posts
${postLines.join('\n')}
## About
- [About the site](${abs('/about/')})
- [Legal & disclosures](${abs('/legal/')}): affiliate-link policy, privacy
`;
return new Response(body, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
+6 -38
View File
@@ -1,43 +1,11 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { getFeedItems, renderRss, FEED_TITLE, FEED_DESCRIPTION } from '../lib/feed';
export async function GET(context) {
const [reviews, posts, bundles] = await Promise.all([
getCollection('reviews'),
getCollection('posts'),
getCollection('bundles'),
]);
const items = [
...reviews.map((r) => ({
title: r.data.name,
description: r.data.description ?? r.data.subtitle ?? '',
pubDate: r.data.date,
link: `/reviews/${r.id}/`,
categories: [r.data.category, ...r.data.tags],
})),
...posts.map((p) => ({
title: p.data.title,
description: p.data.description ?? '',
pubDate: p.data.date,
link: `/posts/${p.id}/`,
categories: [p.data.category, ...p.data.tags],
})),
...bundles.map((b) => ({
title: b.data.title,
description: b.data.description ?? b.data.blurb ?? '',
pubDate: b.data.date,
link: `/bundles/${b.id}/`,
categories: [b.data.category, ...b.data.tags],
})),
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
return rss({
title: 'Unique',
description:
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
return renderRss({
site: context.site,
items,
customData: '<language>en-us</language>',
path: '/rss.xml',
title: FEED_TITLE,
description: FEED_DESCRIPTION,
items: await getFeedItems(),
});
}
+1
View File
@@ -60,6 +60,7 @@ const breadcrumbs = buildBreadcrumbs([
lastUpdated={lastUpdated}
ogImage={ogImage}
jsonLd={[collectionPage, itemList, breadcrumbs]}
feeds={[{ title: `Unique — #${tag} (RSS)`, href: `/tags/${tag}/rss.xml` }]}
wide
>
<EntryGrid entries={entries} />
+18
View File
@@ -0,0 +1,18 @@
import { getFeedItems, getFeedTags, renderRss, FEED_TITLE } from '../../../lib/feed';
export async function getStaticPaths() {
const tags = await getFeedTags();
return tags.map(({ name }) => ({ params: { tag: name } }));
}
export async function GET(context) {
const { tag } = context.params;
const items = (await getFeedItems()).filter((i) => i.tags.includes(tag));
return renderRss({
site: context.site,
path: `/tags/${tag}/rss.xml`,
title: `${FEED_TITLE} — #${tag}`,
description: `Entries tagged #${tag} on ${FEED_TITLE}.`,
items,
});
}