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:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## July 2026
|
## July 2026
|
||||||
|
|
||||||
|
- 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; 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
|
||||||
- Autonomous pipeline overhaul: new `/daily-pipeline` skill chains capture → bundle → review graduation → pick-of-the-day → build → commit → push and ends with a digest; `daily-finds` gains a score-and-shortlist stage (up to 12 `shortlist: true` finds per run, top 1-3 flagged as review candidates), feed-first fetching (`fetch: "feed"` + `feedUrl` in sources.json), cadence-aware scanning, and authority to apply mechanical source maintenance itself; `build-bundle`/`cluster-bundles`/`build-review` gain unattended `auto` modes (publish + log instead of pausing for confirmation), and `build-bundle weekly` writes a date-based "week's catch" so shortlisted finds never sit unbundled for weeks
|
- Autonomous pipeline overhaul: new `/daily-pipeline` skill chains capture → bundle → review graduation → pick-of-the-day → build → commit → push and ends with a digest; `daily-finds` gains a score-and-shortlist stage (up to 12 `shortlist: true` finds per run, top 1-3 flagged as review candidates), feed-first fetching (`fetch: "feed"` + `feedUrl` in sources.json), cadence-aware scanning, and authority to apply mechanical source maintenance itself; `build-bundle`/`cluster-bundles`/`build-review` gain unattended `auto` modes (publish + log instead of pausing for confirmation), and `build-bundle weekly` writes a date-based "week's catch" so shortlisted finds never sit unbundled for weeks
|
||||||
- Review heroes are now auto-discovered: `src/lib/hero.ts` globs `reviews/*/hero.<ext>` (images and `hero.mp4`), so a new review needs no registration — only legacy non-`hero`-named assets remain as explicit overrides; both hero globs also accept `.avif`
|
- Review heroes are now auto-discovered: `src/lib/hero.ts` globs `reviews/*/hero.<ext>` (images and `hero.mp4`), so a new review needs no registration — only legacy non-`hero`-named assets remain as explicit overrides; both hero globs also accept `.avif`
|
||||||
- Pick of the day moves from a hardcoded constant in `src/pages/index.astro` to `src/data/pick.json` (falls back to the latest review); the dev-mode "Make today's pick" button writes the JSON file
|
- Pick of the day moves from a hardcoded constant in `src/pages/index.astro` to `src/data/pick.json` (falls back to the latest review); the dev-mode "Make today's pick" button writes the JSON file
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ Built with [Astro](https://astro.build) + MDX.
|
|||||||
- 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*)
|
||||||
- Auto-generated `/categories/<name>/` and `/tags/<name>/` listing pages — finds excluded by design
|
- Auto-generated `/categories/<name>/` and `/tags/<name>/` listing pages — finds excluded by design
|
||||||
- **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, and JSON-LD; per-page structured data (`Review`, `BlogPosting`, `Product`, `Article`, `CollectionPage`, `ItemList`, `BreadcrumbList`, `WebSite`, `Organization`); auto-generated `sitemap-index.xml` (excludes `/grid/` and `/index3/`); RSS feed at `/rss.xml`; `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
|
||||||
|
- **Feeds everywhere**: site-wide RSS 2.0 at `/rss.xml` and JSON Feed 1.1 at `/feed.json`, plus per-category (`/categories/<name>/rss.xml`) and per-tag (`/tags/<name>/rss.xml`) RSS feeds, all derived from a single item source in `src/lib/feed.ts`; category/tag pages advertise their scoped feed via `<link rel="alternate">`; a human-readable directory at `/feeds/` (footer-linked) lists every feed with counts; RSS renders as a readable page in browsers via `public/feed.xsl`
|
||||||
|
- **Programmatic surface**: build-generated `/llms.txt` (llmstxt.org format — site map with every review/bundle/post and one-line descriptions, plus feed and endpoint pointers) and the full-site JSON index at `/search.json`, both documented in the "For robots" section of `/feeds/`
|
||||||
- Custom 404 page with recent-reviews and navigation fallback
|
- Custom 404 page with recent-reviews and navigation fallback
|
||||||
- Light/dark mode via `prefers-color-scheme`
|
- Light/dark mode via `prefers-color-scheme`
|
||||||
- **Cloudflare Web Analytics** beacon in `BaseLayout.astro` for cookieless, privacy-preserving page-view + referrer + Core Web Vitals tracking scoped to `unique.rzen.dev`
|
- **Cloudflare Web Analytics** beacon in `BaseLayout.astro` for cookieless, privacy-preserving page-view + referrer + Core Web Vitals tracking scoped to `unique.rzen.dev`
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Browser-facing rendering for the site's RSS feeds. Feed readers ignore
|
||||||
|
this entirely; it only makes /rss.xml (and the per-category/per-tag
|
||||||
|
feeds) human-friendly when opened in a browser.
|
||||||
|
-->
|
||||||
|
<xsl:stylesheet version="1.0"
|
||||||
|
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
||||||
|
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
|
||||||
|
<xsl:template match="/rss/channel">
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title><xsl:value-of select="title"/> — feed</title>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
background: #f4f0ec;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-family: ui-serif, Georgia, 'Iowan Old Style', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
a { color: #b34700; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.notice {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #ebe6df;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b6b6b;
|
||||||
|
}
|
||||||
|
.notice code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
h1 { font-family: ui-sans-serif, system-ui, sans-serif; letter-spacing: -0.015em; margin: 1.5rem 0 0.25rem; }
|
||||||
|
.tagline { margin: 0 0 2rem; font-style: italic; color: #6b6b6b; }
|
||||||
|
.item { margin: 0 0 1.5rem; }
|
||||||
|
.item h2 { margin: 0; font-size: 1.1rem; font-family: ui-sans-serif, system-ui, sans-serif; letter-spacing: -0.015em; }
|
||||||
|
.item p { margin: 0.2rem 0 0; }
|
||||||
|
.item .date {
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #6b6b6b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p class="notice">
|
||||||
|
This is an <strong>RSS feed</strong>. Copy this page's address into
|
||||||
|
your feed reader to subscribe. All feeds are listed at
|
||||||
|
<a href="/feeds/">/feeds/</a>.
|
||||||
|
</p>
|
||||||
|
<h1><xsl:value-of select="title"/></h1>
|
||||||
|
<p class="tagline"><xsl:value-of select="description"/></p>
|
||||||
|
<xsl:for-each select="item">
|
||||||
|
<div class="item">
|
||||||
|
<h2>
|
||||||
|
<a>
|
||||||
|
<xsl:attribute name="href"><xsl:value-of select="link"/></xsl:attribute>
|
||||||
|
<xsl:value-of select="title"/>
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<p class="date"><xsl:value-of select="pubDate"/></p>
|
||||||
|
<p><xsl:value-of select="description"/></p>
|
||||||
|
</div>
|
||||||
|
</xsl:for-each>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</xsl:template>
|
||||||
|
</xsl:stylesheet>
|
||||||
@@ -19,6 +19,8 @@ interface Props {
|
|||||||
modifiedTime?: Date;
|
modifiedTime?: Date;
|
||||||
articleTags?: string[];
|
articleTags?: string[];
|
||||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
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 {
|
const {
|
||||||
@@ -33,6 +35,7 @@ const {
|
|||||||
modifiedTime,
|
modifiedTime,
|
||||||
articleTags = [],
|
articleTags = [],
|
||||||
jsonLd,
|
jsonLd,
|
||||||
|
feeds = [],
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const SITE_NAME = 'Unique';
|
const SITE_NAME = 'Unique';
|
||||||
@@ -59,6 +62,10 @@ const jsonLdBlocks = jsonLd
|
|||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#fafaf7" />
|
<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/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:type" content={ogType} />
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface Props {
|
|||||||
modifiedTime?: Date;
|
modifiedTime?: Date;
|
||||||
articleTags?: string[];
|
articleTags?: string[];
|
||||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
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). */
|
/** Opt into the wider content tier on roomy viewports (grid/listing pages). */
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
}
|
}
|
||||||
@@ -40,6 +42,7 @@ const {
|
|||||||
modifiedTime,
|
modifiedTime,
|
||||||
articleTags,
|
articleTags,
|
||||||
jsonLd,
|
jsonLd,
|
||||||
|
feeds,
|
||||||
wide,
|
wide,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
const siteName = 'Unique';
|
const siteName = 'Unique';
|
||||||
@@ -77,6 +80,7 @@ const year = new Date().getFullYear();
|
|||||||
modifiedTime={modifiedTime}
|
modifiedTime={modifiedTime}
|
||||||
articleTags={articleTags}
|
articleTags={articleTags}
|
||||||
jsonLd={jsonLd}
|
jsonLd={jsonLd}
|
||||||
|
feeds={feeds}
|
||||||
/>
|
/>
|
||||||
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "fc978456945c49ae90a0b7e0b891ffd2"}'></script>
|
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "fc978456945c49ae90a0b7e0b891ffd2"}'></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -123,6 +127,7 @@ const year = new Date().getFullYear();
|
|||||||
<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="/sources/">Sources</a></li>
|
<li><a href="/sources/">Sources</a></li>
|
||||||
|
<li><a href="/feeds/">Feeds</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
+130
@@ -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));
|
||||||
|
}
|
||||||
@@ -106,6 +106,14 @@ export const WEBSITE = {
|
|||||||
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
|
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
|
||||||
publisher: { '@id': `${SITE_URL}/#org` },
|
publisher: { '@id': `${SITE_URL}/#org` },
|
||||||
inLanguage: 'en-US',
|
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([
|
const SOFTWARE_CATEGORIES = new Set([
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
ogImage={ogImage}
|
ogImage={ogImage}
|
||||||
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||||
|
feeds={[{ title: `Unique — ${category} (RSS)`, href: `/categories/${category}/rss.xml` }]}
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<EntryGrid entries={entries} />
|
<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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -1,43 +1,11 @@
|
|||||||
import rss from '@astrojs/rss';
|
import { getFeedItems, renderRss, FEED_TITLE, FEED_DESCRIPTION } from '../lib/feed';
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
|
|
||||||
export async function GET(context) {
|
export async function GET(context) {
|
||||||
const [reviews, posts, bundles] = await Promise.all([
|
return renderRss({
|
||||||
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.',
|
|
||||||
site: context.site,
|
site: context.site,
|
||||||
items,
|
path: '/rss.xml',
|
||||||
customData: '<language>en-us</language>',
|
title: FEED_TITLE,
|
||||||
|
description: FEED_DESCRIPTION,
|
||||||
|
items: await getFeedItems(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
ogImage={ogImage}
|
ogImage={ogImage}
|
||||||
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||||
|
feeds={[{ title: `Unique — #${tag} (RSS)`, href: `/tags/${tag}/rss.xml` }]}
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<EntryGrid entries={entries} />
|
<EntryGrid entries={entries} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user