site: tag pages include finds; find-only tags get pages
Tag pages are now 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. Tagged finds render in a compact secondary "Finds" list below the entry grid, keeping the editorial tier on top. Find-only tag pages with fewer than 3 finds are noindexed until they fill out. Tag feeds stay entries-only; the RSS pill and autodiscovery link only render where a feed exists. Category pages unchanged (finds have no category). Claude-Session: https://claude.ai/code/session_01WZaczDJjL3xZ3u5spsN5AL
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## July 2026
|
## July 2026
|
||||||
|
|
||||||
|
- Tag pages now include finds: `/tags/<tag>/` is generated from the union of entry and find tags (1,179 tags that existed only on finds — most tag links on find pages — previously 404'd) and lists tagged finds in a compact secondary section below the entry grid, keeping reviews/bundles/posts visually on top. Find-only tag pages with fewer than 3 finds are noindexed until they fill out; tag feeds stay entries-only, and the RSS pill only renders where a feed exists. Category pages are unaffected (finds have no category)
|
||||||
- Feeds for everything: alongside the existing `/rss.xml`, add a JSON Feed 1.1 at `/feed.json`, per-category RSS at `/categories/<name>/rss.xml`, and per-tag RSS at `/tags/<name>/rss.xml` — all derived from a shared `src/lib/feed.ts` item source (reviews + posts + bundles, same visibility rules as the site). Category/tag pages advertise their scoped feed via `<link rel="alternate">` autodiscovery and show a visible RSS pill in the subheader (browsers dropped feed-detection UI years ago, so autodiscovery alone is invisible); a new `/feeds/` directory page (linked from the footer) lists every feed with entry counts; every RSS feed carries an `atom:link` self reference and renders as a readable page in browsers via `public/feed.xsl`
|
- Feeds for everything: alongside the existing `/rss.xml`, add a JSON Feed 1.1 at `/feed.json`, per-category RSS at `/categories/<name>/rss.xml`, and per-tag RSS at `/tags/<name>/rss.xml` — all derived from a shared `src/lib/feed.ts` item source (reviews + posts + bundles, same visibility rules as the site). Category/tag pages advertise their scoped feed via `<link rel="alternate">` autodiscovery and show a visible RSS pill in the subheader (browsers dropped feed-detection UI years ago, so autodiscovery alone is invisible); a new `/feeds/` directory page (linked from the footer) lists every feed with entry counts; every RSS feed carries an `atom:link` self reference and renders as a readable page in browsers via `public/feed.xsl`
|
||||||
- Programmatic-reader pass: build-generated `/llms.txt` (llmstxt.org format — site description, feed/endpoint map, and every review, bundle, and post with one-line descriptions), `SearchAction` on the home-page `WebSite` JSON-LD wired to `/search/?q=`, and a "For robots" section on `/feeds/` documenting `search.json`, `llms.txt`, the sitemap, and JSON-LD coverage
|
- Programmatic-reader pass: build-generated `/llms.txt` (llmstxt.org format — site description, feed/endpoint map, and every review, bundle, and post with one-line descriptions), `SearchAction` on the home-page `WebSite` JSON-LD wired to `/search/?q=`, and a "For robots" section on `/feeds/` documenting `search.json`, `llms.txt`, the sitemap, and JSON-LD coverage
|
||||||
- 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
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Unattended runs never pause for confirmation — they publish, log to `daily-fin
|
|||||||
- Review heroes are auto-discovered from `src/content/reviews/<slug>/hero.<ext>` (`src/lib/hero.ts` glob) — name the file `hero.*`; no registration.
|
- Review heroes are auto-discovered from `src/content/reviews/<slug>/hero.<ext>` (`src/lib/hero.ts` glob) — name the file `hero.*`; no registration.
|
||||||
- The homepage "pick of the day" is `src/data/pick.json` (`{"hero": "<review-slug>"}`); falls back to the latest review.
|
- The homepage "pick of the day" is `src/data/pick.json` (`{"hero": "<review-slug>"}`); falls back to the latest review.
|
||||||
- Find slugs are globally unique although folders are date-bucketed; URLs stay flat at `/find/<slug>/`.
|
- Find slugs are globally unique although folders are date-bucketed; URLs stay flat at `/find/<slug>/`.
|
||||||
- Finds are only reachable through bundles — an unbundled find is invisible on the site.
|
- Finds are reachable through bundles and through tag pages (a secondary "Finds" list under the entry grid on `/tags/<tag>/`) — an unbundled find is still invisible on the home feed, grids, and category pages.
|
||||||
- `source` frontmatter must exactly match a `name` in `sources.json`.
|
- `source` frontmatter must exactly match a `name` in `sources.json`.
|
||||||
- Amazon links: store canonical `https://www.amazon.com/dp/<ASIN>` with no query params; `src/lib/affiliate.ts` adds the tracking ID, badge, and disclosure at render time.
|
- Amazon links: store canonical `https://www.amazon.com/dp/<ASIN>` with no query params; `src/lib/affiliate.ts` adds the tracking ID, badge, and disclosure at render time.
|
||||||
- Commit style: see `git log` — `content: …` for capture runs, `site: …` for everything user-visible; one commit per pipeline stage.
|
- Commit style: see `git log` — `content: …` for capture runs, `site: …` for everything user-visible; one commit per pipeline stage.
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ Built with [Astro](https://astro.build) + MDX.
|
|||||||
- **Autonomous daily pipeline** under `.claude/skills/`: `daily-pipeline` (the unattended chain: capture → bundle → review graduation → pick of the day → build → commit → push → digest); `daily-finds` (sweeps every source in `sources.json`, writes one find file per qualifying item, marks a ranked shortlist of up to 12, adds Amazon links for matched physical products, and self-maintains `sources.json`); `build-bundle` (thematic bundle from the find pool; `auto` and `weekly` catch modes); `cluster-bundles` (batch-clusters the find pool into multiple bundles); `build-review` (graduates a find to a full review with a mandatory fact-check in auto mode, linking the two via `fromFind` / `promotedTo`)
|
- **Autonomous daily pipeline** under `.claude/skills/`: `daily-pipeline` (the unattended chain: capture → bundle → review graduation → pick of the day → build → commit → push → digest); `daily-finds` (sweeps every source in `sources.json`, writes one find file per qualifying item, marks a ranked shortlist of up to 12, adds Amazon links for matched physical products, and self-maintains `sources.json`); `build-bundle` (thematic bundle from the find pool; `auto` and `weekly` catch modes); `cluster-bundles` (batch-clusters the find pool into multiple bundles); `build-review` (graduates a find to a full review with a mandatory fact-check in auto mode, linking the two via `fromFind` / `promotedTo`)
|
||||||
- **Pick of the day** driven by `src/data/pick.json` (falls back to the latest review) — the homepage hero is a one-line content edit, also settable from the dev-mode button on any review page
|
- **Pick of the day** driven by `src/data/pick.json` (falls back to the latest review) — the homepage hero is a one-line content edit, also settable from the dev-mode button on any review page
|
||||||
- **Sources catalog** at `sources.json` with per-source fetch strategy (`feed` / `webfetch` / `curl` / `skip`, feeds preferred) and the `/sources` page rendering a 3-column card stream with a "Recent finds" sub-list per card
|
- **Sources catalog** at `sources.json` with per-source fetch strategy (`feed` / `webfetch` / `curl` / `skip`, feeds preferred) and the `/sources` page rendering a 3-column card stream with a "Recent finds" sub-list per card
|
||||||
- **Strict find visibility**: find items are reachable only via the bundles that reference them, the `/sources` cross-reference, or direct URL — they don't crowd the home feed, tag pages, or category pages
|
- **Strict find visibility**: find items are reachable via the bundles that reference them, the `/sources` cross-reference, tag pages (a secondary "Finds" list below the entry grid), or direct URL — they don't crowd the home feed, grids, or category pages
|
||||||
- **Backlinks** computed at build time: each find page lists every bundle that references it; promoted finds show a forward link to the review; reviews show a "← First surfaced…" backlink to their originating find
|
- **Backlinks** computed at build time: each find page lists every bundle that references it; promoted finds show a forward link to the review; reviews show a "← First surfaced…" backlink to their originating find
|
||||||
- **Newspaper-style masthead** on every page: live entry-count + last-updated stats on the left, large serif "Unique" wordmark centered between thin rules, About/Blog nav on the right; per-page italic subheader ("A small log of delightful things.") with last-updated date on listing pages
|
- **Newspaper-style masthead** on every page: live entry-count + last-updated stats on the left, large serif "Unique" wordmark centered between thin rules, About/Blog nav on the right; per-page italic subheader ("A small log of delightful things.") with last-updated date on listing pages
|
||||||
- **Pinterest-style home page**: hero card for the latest review plus a 3-column masonry grid of remaining reviews with a "Load more" reveal; old plain-list home preserved at `/index3/`
|
- **Pinterest-style home page**: hero card for the latest review plus a 3-column masonry grid of remaining reviews with a "Load more" reveal; old plain-list home preserved at `/index3/`
|
||||||
- **Unified chronological feed** at `/index3/` across reviews + bundles + posts
|
- **Unified chronological feed** at `/index3/` across reviews + bundles + posts
|
||||||
- **SEO-friendly site footer** with Browse / Collections (live counts) / About link columns and a centered legal line; consistent 76rem page width across the entire site (article prose constrained to a comfortable reading measure inside the wider layout)
|
- **SEO-friendly site footer** with Browse / Collections (live counts) / About link columns and a centered legal line; consistent 76rem page width across the entire site (article prose constrained to a comfortable reading measure inside the wider layout)
|
||||||
- Reviews render as two-line index entries: bold name + muted subtitle (`Cotypist` / *Autocomplete that lives everywhere on your Mac*)
|
- Reviews render as two-line index entries: bold name + muted subtitle (`Cotypist` / *Autocomplete that lives everywhere on your Mac*)
|
||||||
- Auto-generated `/categories/<name>/` and `/tags/<name>/` listing pages — finds excluded by design
|
- Auto-generated `/categories/<name>/` and `/tags/<name>/` listing pages; tag pages are built from the union of entry and find tags (so every tag linked from a find resolves) and list tagged finds in a secondary section — find-only tag pages with fewer than 3 finds are noindexed until they accumulate content; category pages stay entries-only (finds have no category)
|
||||||
- **Amazon affiliate handling** in `src/lib/affiliate.ts`: outbound links to Amazon (`amazon.com`, `amzn.to`, `a.co`, `smile.amazon.com`) get the configured tracking ID appended automatically; the link gets `rel="sponsored"`, an "Affiliate" badge appears next to it, and a one-line disclosure is shown beneath. Finds and reviews also take an optional `amazonLink` frontmatter field (canonical `/dp/<ASIN>` form) rendered as an "Also on Amazon" link — the capture pipeline populates it when a shortlisted physical product has an exact Amazon match. Add additional marketplace IDs (e.g. `amazon.co.uk`) to the `AMAZON_TAGS` map when needed
|
- **Amazon affiliate handling** in `src/lib/affiliate.ts`: outbound links to Amazon (`amazon.com`, `amzn.to`, `a.co`, `smile.amazon.com`) get the configured tracking ID appended automatically; the link gets `rel="sponsored"`, an "Affiliate" badge appears next to it, and a one-line disclosure is shown beneath. Finds and reviews also take an optional `amazonLink` frontmatter field (canonical `/dp/<ASIN>` form) rendered as an "Also on Amazon" link — the capture pipeline populates it when a shortlisted physical product has an exact Amazon match. Add additional marketplace IDs (e.g. `amazon.co.uk`) to the `AMAZON_TAGS` map when needed
|
||||||
- **SEO surface**: single `<SEO>` component owning canonical, Open Graph, Twitter Card, feed autodiscovery, and JSON-LD; per-page structured data (`Review`, `BlogPosting`, `Product`, `Article`, `CollectionPage`, `ItemList`, `BreadcrumbList`, `WebSite` with `SearchAction`, `Organization`); auto-generated `sitemap-index.xml` (excludes `/grid/` and `/index3/`); `robots.txt`; static OG image fallbacks under `/public/og/`; promoted finds canonical to their review
|
- **SEO surface**: single `<SEO>` component owning canonical, Open Graph, Twitter Card, feed autodiscovery, and JSON-LD; per-page structured data (`Review`, `BlogPosting`, `Product`, `Article`, `CollectionPage`, `ItemList`, `BreadcrumbList`, `WebSite` with `SearchAction`, `Organization`); auto-generated `sitemap-index.xml` (excludes `/grid/` and `/index3/`); `robots.txt`; static OG image fallbacks under `/public/og/`; promoted finds canonical to their review
|
||||||
- **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">` and show a visible RSS pill in the subheader; 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`
|
- **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">` and show a visible RSS pill in the subheader; 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`
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@ const feedSlashShim = {
|
|||||||
name: 'feed-trailing-slash-dev-shim',
|
name: 'feed-trailing-slash-dev-shim',
|
||||||
apply: 'serve',
|
apply: 'serve',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
if (/^\/(tags|categories)\/[^/]+\/rss\.xml$/.test(req.url)) req.url += '/';
|
if (/^\/(tags|categories)\/[^/]+\/rss\.xml$/.test(req.url)) req.url += '/';
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
---
|
---
|
||||||
import type { Entry } from '../lib/entries';
|
// Structural subset of lib/entries' Entry so callers can also feed mapped
|
||||||
|
// find items (tag pages) without pretending they're full entries.
|
||||||
interface Props {
|
interface Props {
|
||||||
entries: Entry[];
|
entries: Array<{
|
||||||
|
href: string;
|
||||||
|
date: Date;
|
||||||
|
primary: string;
|
||||||
|
secondary?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { entries } = Astro.props;
|
const { entries } = Astro.props;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
title="Feeds"
|
title="Feeds"
|
||||||
description={description}
|
description={description}
|
||||||
subheader="Take Unique with you."
|
subheader="Take Unique with you."
|
||||||
|
ogImage={ogImage}
|
||||||
jsonLd={[collectionPage, breadcrumbs]}
|
jsonLd={[collectionPage, breadcrumbs]}
|
||||||
>
|
>
|
||||||
<h1>Feeds</h1>
|
<h1>Feeds</h1>
|
||||||
|
|||||||
+60
-10
@@ -1,7 +1,9 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import EntryGrid from '../../components/EntryGrid.astro';
|
import EntryGrid from '../../components/EntryGrid.astro';
|
||||||
|
import EntryList from '../../components/EntryList.astro';
|
||||||
import { getAllEntries } from '../../lib/entries';
|
import { getAllEntries } from '../../lib/entries';
|
||||||
|
import { getAllFinds } from '../../lib/find-items';
|
||||||
import {
|
import {
|
||||||
buildOgImage,
|
buildOgImage,
|
||||||
buildBreadcrumbs,
|
buildBreadcrumbs,
|
||||||
@@ -11,29 +13,58 @@ import {
|
|||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const entries = await getAllEntries();
|
const entries = await getAllEntries();
|
||||||
const tags = [...new Set(entries.flatMap((e) => e.tags))];
|
const finds = await getAllFinds();
|
||||||
return tags.map((tag) => ({
|
const tags = new Set([
|
||||||
|
...entries.flatMap((e) => e.tags),
|
||||||
|
...finds.flatMap((f) => f.data.tags),
|
||||||
|
]);
|
||||||
|
return [...tags].map((tag) => ({
|
||||||
params: { tag },
|
params: { tag },
|
||||||
props: {
|
props: {
|
||||||
tag,
|
tag,
|
||||||
entries: entries.filter((e) => e.tags.includes(tag)),
|
entries: entries.filter((e) => e.tags.includes(tag)),
|
||||||
|
finds: finds.filter((f) => f.data.tags.includes(tag)),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tag, entries } = Astro.props;
|
const { tag, entries, finds } = Astro.props;
|
||||||
const lastUpdated = entries[0]?.date;
|
|
||||||
const count = entries.length;
|
const findRows = finds.map((f) => ({
|
||||||
const topNames = entries.slice(0, 3).map((e) => e.primary).join(', ');
|
href: `/find/${f.id}/`,
|
||||||
|
date: f.data.date,
|
||||||
|
primary: f.data.name,
|
||||||
|
secondary: f.data.subtitle ?? f.data.description,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const count = entries.length + finds.length;
|
||||||
|
const lastUpdated = [entries[0]?.date, finds[0]?.data.date]
|
||||||
|
.filter((d): d is Date => Boolean(d))
|
||||||
|
.sort((a, b) => b.valueOf() - a.valueOf())[0];
|
||||||
|
|
||||||
|
const topNames = [...entries.map((e) => e.primary), ...finds.map((f) => f.data.name)]
|
||||||
|
.slice(0, 3)
|
||||||
|
.join(', ');
|
||||||
const description =
|
const description =
|
||||||
topNames.length > 0
|
topNames.length > 0
|
||||||
? `All ${count} entries tagged #${tag}: ${topNames}${count > 3 ? '…' : '.'}`
|
? `All ${count} entries tagged #${tag}: ${topNames}${count > 3 ? '…' : '.'}`
|
||||||
: `All entries tagged #${tag}.`;
|
: `All entries tagged #${tag}.`;
|
||||||
|
|
||||||
|
// Find-only tags with a couple of items are thin; keep them browsable but
|
||||||
|
// out of the index until they accumulate content.
|
||||||
|
const noindex = entries.length === 0 && finds.length < 3;
|
||||||
|
|
||||||
|
// A scoped feed only exists when the tag has entries (feeds mirror the
|
||||||
|
// editorial surface: reviews + posts + bundles).
|
||||||
|
const feedHref = entries.length > 0 ? `/tags/${tag}/rss.xml` : undefined;
|
||||||
|
|
||||||
const ogImage = buildOgImage(undefined, 'site', `#${tag} on Unique`);
|
const ogImage = buildOgImage(undefined, 'site', `#${tag} on Unique`);
|
||||||
|
|
||||||
const itemList = buildItemList(
|
const itemList = buildItemList(
|
||||||
entries.map((e) => ({ url: e.href, name: e.primary })),
|
[
|
||||||
|
...entries.map((e) => ({ url: e.href, name: e.primary })),
|
||||||
|
...finds.map((f) => ({ url: `/find/${f.id}/`, name: f.data.name })),
|
||||||
|
],
|
||||||
`#${tag} on Unique`
|
`#${tag} on Unique`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -59,15 +90,34 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
subheader={`Tagged: ${tag}.`}
|
subheader={`Tagged: ${tag}.`}
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
ogImage={ogImage}
|
ogImage={ogImage}
|
||||||
|
noindex={noindex}
|
||||||
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||||
feeds={[{ title: `Unique — #${tag} (RSS)`, href: `/tags/${tag}/rss.xml` }]}
|
feeds={feedHref ? [{ title: `Unique — #${tag} (RSS)`, href: feedHref }] : undefined}
|
||||||
feedHref={`/tags/${tag}/rss.xml`}
|
feedHref={feedHref}
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<EntryGrid entries={entries} />
|
{entries.length > 0 && <EntryGrid entries={entries} />}
|
||||||
|
|
||||||
|
{finds.length > 0 && (
|
||||||
|
<section class="tag-finds">
|
||||||
|
<h2>Finds</h2>
|
||||||
|
<EntryList entries={findRows} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<p class="back"><a href="/">← back</a></p>
|
<p class="back"><a href="/">← back</a></p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.tag-finds {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
}
|
||||||
|
.tag-finds:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.tag-finds h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||||
</style>
|
</style>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user