site: rename "finds" superpost collection to "bundles"

Disambiguates the one-letter `find` (item) vs `finds` (roundup) split that
kept causing confusion. Audience-facing now: `/bundles/<slug>/`, "Bundle"
labels in home/footer/search.

Internals: `getCollection('finds')` → `('bundles')`, `Entry.type 'finds'`
→ `'bundle'`, `OgFallback 'finds'` → `'bundles'`, `findListMosaic` →
`bundleMosaic`, `FindsPost` → `Bundle`, `findsReferencing` →
`bundlesReferencing`. Build script + generated mosaic dir follow
(`scripts/build-bundle-mosaics.mjs`, `src/generated/bundle-mosaics/`).

Skills `build-finds` → `build-bundle`, `cluster-finds` →
`cluster-bundles`. The `find` collection (individual items) is unchanged.

No URL redirects: low external-link surface area.
This commit is contained in:
2026-05-06 20:43:22 -04:00
parent c2e1df222d
commit 049c6c5377
36 changed files with 503 additions and 255 deletions
@@ -13,8 +13,8 @@ import {
} from '../../lib/seo';
export async function getStaticPaths() {
const finds = await getCollection('finds');
return finds.map((post) => ({
const bundles = await getCollection('bundles');
return bundles.map((post) => ({
params: { slug: post.id },
props: { post },
}));
@@ -29,17 +29,17 @@ const description =
const ogImage = buildOgImage(
items[0]?.data.promotedTo,
'finds',
'bundles',
`${post.data.title} cover image`
);
const articleSchema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Article',
'@id': absoluteUrl(`/finds/${post.id}/#article`),
'@id': absoluteUrl(`/bundles/${post.id}/#article`),
headline: post.data.title,
description,
url: absoluteUrl(`/finds/${post.id}/`),
url: absoluteUrl(`/bundles/${post.id}/`),
datePublished: post.data.date.toISOString(),
author: PERSON_AUTHOR,
image: ogImage.url,
@@ -53,7 +53,7 @@ const itemList = buildItemList(
const breadcrumbs = buildBreadcrumbs([
{ name: 'Home', url: '/' },
{ name: post.data.title, url: `/finds/${post.id}/` },
{ name: post.data.title, url: `/bundles/${post.id}/` },
]);
---
@@ -78,15 +78,34 @@ const breadcrumbs = buildBreadcrumbs([
{items.map((item) => <FindCard item={item} />)}
</Masonry>
{
post.data.tags.length > 0 && (
<footer class="tags">
{post.data.tags.map((tag) => (
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
))}
</footer>
)
}
{(post.data.tags.length > 0 ||
(post.data.category && post.data.category !== 'bundles')) && (
<aside class="colophon" aria-label="Colophon">
<dl>
{post.data.category && post.data.category !== 'bundles' && (
<>
<dt>Filed under</dt>
<dd>
<a href={`/categories/${post.data.category}/`}>
{post.data.category.charAt(0).toUpperCase() +
post.data.category.slice(1)}
</a>
</dd>
</>
)}
{post.data.tags.length > 0 && (
<>
<dt>Tagged</dt>
<dd class="colophon-tags">
{post.data.tags.map((tag) => (
<a href={`/tags/${tag}/`}>#{tag}</a>
))}
</dd>
</>
)}
</dl>
</aside>
)}
<p class="back"><a href="/">← back</a></p>
</article>
@@ -105,16 +124,6 @@ const breadcrumbs = buildBreadcrumbs([
font-style: normal;
}
.body :global(blockquote p) { margin: 0; }
.tags {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
@@ -3,12 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro';
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import { findListMosaic } from '../../lib/find-list-mosaic';
import { bundleMosaic } from '../../lib/bundle-mosaic';
const lists = (await getCollection('finds')).sort(
const bundles = (await getCollection('bundles')).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
const lastUpdated = lists[0]?.data.date;
const lastUpdated = bundles[0]?.data.date;
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
@@ -19,18 +19,18 @@ const fmt = new Intl.DateTimeFormat('en-US', {
---
<BaseLayout
title="Lists"
description={`All ${lists.length} themed lists on Unique.`}
subheader={`All ${lists.length} themed lists.`}
title="Bundles"
description={`All ${bundles.length} themed bundles on Unique.`}
subheader={`All ${bundles.length} themed bundles.`}
lastUpdated={lastUpdated}
noindex
>
<Masonry>
{lists.map((l) => {
const mosaic = findListMosaic(l.id);
{bundles.map((b) => {
const mosaic = bundleMosaic(b.id);
return (
<article>
<a href={`/finds/${l.id}/`} class="list-tile">
<a href={`/bundles/${b.id}/`} class="bundle-tile">
{mosaic && (
<div class="mosaic">
<Image
@@ -44,12 +44,12 @@ const fmt = new Intl.DateTimeFormat('en-US', {
</div>
)}
<div class="body">
<h2 class="title">{l.data.title}</h2>
{l.data.blurb && <p class="blurb">{l.data.blurb}</p>}
<h2 class="title">{b.data.title}</h2>
{b.data.blurb && <p class="blurb">{b.data.blurb}</p>}
<p class="meta">
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
<time datetime={b.data.date.toISOString()}>{fmt.format(b.data.date)}</time>
<span class="dot" aria-hidden="true">·</span>
<span>{l.data.items.length} picks</span>
<span>{b.data.items.length} picks</span>
</p>
</div>
</a>
@@ -59,7 +59,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
</Masonry>
<style>
.list-tile {
.bundle-tile {
display: block;
overflow: hidden;
border-radius: 8px;
@@ -67,12 +67,12 @@ const fmt = new Intl.DateTimeFormat('en-US', {
color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.list-tile:hover {
.bundle-tile:hover {
text-decoration: none;
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent);
}
.list-tile:hover .title { color: var(--accent); }
.bundle-tile:hover .title { color: var(--accent); }
.mosaic {
display: block;
+22 -65
View File
@@ -2,10 +2,10 @@
import { getCollection, render } from 'astro:content';
import { Image } from 'astro:assets';
import BaseLayout from '../../layouts/BaseLayout.astro';
import SourceBadge from '../../components/SourceBadge.astro';
import Backlinks from '../../components/Backlinks.astro';
import { findsReferencing } from '../../lib/backlinks';
import { bundlesReferencing } from '../../lib/backlinks';
import { findHero } from '../../lib/find-hero';
import { sourceSlug } from '../../lib/sources';
import EditAction from '../../components/EditAction.astro';
import {
resolveExternalLink,
@@ -24,7 +24,7 @@ export async function getStaticPaths() {
const { item } = Astro.props;
const { Content } = await render(item);
const featuringPosts = await findsReferencing(item.id);
const featuringPosts = await bundlesReferencing(item.id);
const hero = findHero(item.id);
const linkLabel =
@@ -41,7 +41,7 @@ const canonicalOverride = item.data.promotedTo
const ogImage = buildOgImage(
item.data.promotedTo,
'finds',
'bundles',
`${item.data.name} preview image`
);
@@ -82,10 +82,6 @@ const breadcrumbs = buildBreadcrumbs([
jsonLd={[productSchema, breadcrumbs]}
>
<article>
<header class="find-header">
<p class="source-line"><SourceBadge source={item.data.source} /></p>
</header>
{hero && (
<Image
class="hero"
@@ -138,24 +134,29 @@ const breadcrumbs = buildBreadcrumbs([
<Backlinks posts={featuringPosts} />
{
item.data.tags.length > 0 && (
<footer class="tags">
{item.data.tags.map((tag) => (
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
))}
</footer>
)
}
<aside class="colophon" aria-label="Colophon">
<dl>
<dt>Via</dt>
<dd>
<a href={`/sources/#${sourceSlug(item.data.source)}`}>{item.data.source}</a>
</dd>
{item.data.tags.length > 0 && (
<>
<dt>Tagged</dt>
<dd class="colophon-tags">
{item.data.tags.map((tag) => (
<a href={`/tags/${tag}/`}>#{tag}</a>
))}
</dd>
</>
)}
</dl>
</aside>
<p class="back"><a href="/">← back</a></p>
</article>
<style>
.find-header {
margin-bottom: 1.5rem;
}
.source-line { margin: 0; }
.hero {
display: block;
width: 100%;
@@ -168,40 +169,6 @@ const breadcrumbs = buildBreadcrumbs([
margin: 0 0 1.5rem;
}
.body :global(p) { margin: 0 0 1rem; }
.external {
margin: 1.5rem 0 0;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
}
.external a {
display: inline-block;
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--bg);
border-radius: 6px;
font-weight: 600;
font-size: 0.95rem;
}
.external a:hover { text-decoration: none; opacity: 0.9; }
.aff-badge {
display: inline-block;
margin-left: 0.6rem;
padding: 0.15rem 0.5rem;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
background: var(--rule);
border-radius: 999px;
vertical-align: middle;
}
.aff-disclosure {
margin: 0.6rem 0 0;
font-size: 0.78rem;
color: var(--muted);
font-style: italic;
}
.promoted {
margin: 1.5rem 0 0;
padding: 0.85rem 1rem;
@@ -210,16 +177,6 @@ const breadcrumbs = buildBreadcrumbs([
font-size: 0.95rem;
}
.promoted a { font-weight: 600; }
.tags {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
.edit-toolbar {
margin: 2rem 0 0;
+7 -7
View File
@@ -35,14 +35,14 @@ const decorated = (await getAllFinds())
const finds = decorated.map((d) => d.item);
const lastUpdated = finds[0]?.data.date;
// Slugs referenced by any superpost in the `finds/` collection — dev-only filter.
const inListSlugs = new Set(
(await getCollection('finds')).flatMap((l) => l.data.items)
// Slugs referenced by any bundle — dev-only filter.
const bundledSlugs = new Set(
(await getCollection('bundles')).flatMap((b) => b.data.items)
);
const isJoke = (f: (typeof finds)[number]) => f.id.startsWith('joke-');
const isQuote = (f: (typeof finds)[number]) => f.id.startsWith('quote-');
const isUnused = (f: (typeof finds)[number]) =>
!inListSlugs.has(f.id) && !f.data.promotedTo;
!bundledSlugs.has(f.id) && !f.data.promotedTo;
const unusedCount = finds.filter(isUnused).length;
const jokeCount = finds.filter(isJoke).length;
const quoteCount = finds.filter(isQuote).length;
@@ -105,9 +105,9 @@ const quoteCount = finds.filter(isQuote).length;
<span data-find-selected-count>0</span> selected
</span>
<EditAction
action="finds-collection"
label="Make a finds collection"
confirm="This will draft a themed finds list (.mdx in src/content/finds/) from the selected items."
action="bundle-from-selection"
label="Bundle this selection"
confirm="This will draft a themed bundle (.mdx in src/content/bundles/) from the selected items."
/>
</div>
)}
+20 -20
View File
@@ -4,10 +4,10 @@ import { Image } from 'astro:assets';
import BaseLayout from '../layouts/BaseLayout.astro';
import Masonry from '../components/Masonry.astro';
import { heroes } from '../lib/hero';
import { findListMosaic } from '../lib/find-list-mosaic';
import { bundleMosaic } from '../lib/bundle-mosaic';
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
const HERO_SLUG = 'monitorcontrol';
const HERO_SLUG = 'gum-shell';
const INITIAL_GRID = 12;
const BATCH_SIZE = 7;
@@ -22,23 +22,23 @@ type Tile =
hero: ReturnType<typeof getReviewHero>;
}
| {
kind: 'list';
kind: 'bundle';
href: string;
id: string;
name: string;
subtitle: string | undefined;
date: Date;
itemCount: number;
mosaic: ReturnType<typeof findListMosaic>;
mosaic: ReturnType<typeof bundleMosaic>;
};
function getReviewHero(slug: string) {
return heroes[slug];
}
const [reviews, lists] = await Promise.all([
const [reviews, bundles] = await Promise.all([
getCollection('reviews'),
getCollection('finds'),
getCollection('bundles'),
]);
const reviewTiles: Tile[] = reviews.map((review) => ({
@@ -51,18 +51,18 @@ const reviewTiles: Tile[] = reviews.map((review) => ({
hero: getReviewHero(review.id),
}));
const listTiles: Tile[] = lists.map((list) => ({
kind: 'list' as const,
href: `/finds/${list.id}/`,
id: list.id,
name: list.data.title,
subtitle: list.data.blurb,
date: list.data.date,
itemCount: list.data.items.length,
mosaic: findListMosaic(list.id),
const bundleTiles: Tile[] = bundles.map((bundle) => ({
kind: 'bundle' as const,
href: `/bundles/${bundle.id}/`,
id: bundle.id,
name: bundle.data.title,
subtitle: bundle.data.blurb,
date: bundle.data.date,
itemCount: bundle.data.items.length,
mosaic: bundleMosaic(bundle.id),
}));
const tiles: Tile[] = [...reviewTiles, ...listTiles].sort(
const tiles: Tile[] = [...reviewTiles, ...bundleTiles].sort(
(a, b) => b.date.valueOf() - a.date.valueOf()
);
@@ -130,7 +130,7 @@ const itemList = buildItemList(
tile.kind === 'review' ? tile.hero : tile.mosaic ? { kind: 'image' as const, src: tile.mosaic } : undefined;
return (
<article>
<a href={tile.href} class:list={['tile', { 'text-only': !visual, 'is-list': tile.kind === 'list' }]}>
<a href={tile.href} class:list={['tile', { 'text-only': !visual, 'is-bundle': tile.kind === 'bundle' }]}>
{visual?.kind === 'image' && (
<Image
class="media"
@@ -155,8 +155,8 @@ const itemList = buildItemList(
/>
)}
<div class="text">
{tile.kind === 'list' && (
<span class="eyebrow">List · {tile.itemCount} picks</span>
{tile.kind === 'bundle' && (
<span class="eyebrow">Bundle · {tile.itemCount} picks</span>
)}
<span class="name">{tile.name}</span>
{tile.subtitle && <span class="subtitle">{tile.subtitle}</span>}
@@ -260,7 +260,7 @@ const itemList = buildItemList(
color: var(--accent);
font-weight: 600;
}
.tile.is-list .name {
.tile.is-bundle .name {
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-weight: 700;
font-size: 1.15rem;
+8 -8
View File
@@ -2,10 +2,10 @@ import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const [reviews, posts, finds] = await Promise.all([
const [reviews, posts, bundles] = await Promise.all([
getCollection('reviews'),
getCollection('posts'),
getCollection('finds'),
getCollection('bundles'),
]);
const items = [
@@ -23,12 +23,12 @@ export async function GET(context) {
link: `/posts/${p.id}/`,
categories: [p.data.category, ...p.data.tags],
})),
...finds.map((f) => ({
title: f.data.title,
description: f.data.description ?? f.data.blurb ?? '',
pubDate: f.data.date,
link: `/finds/${f.id}/`,
categories: [f.data.category, ...f.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());
+4 -4
View File
@@ -4,7 +4,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<BaseLayout
title="Search"
description="Search reviews, finds, lists, and posts across Unique."
description="Search reviews, finds, bundles, and posts across Unique."
subheader="Search"
noindex
>
@@ -13,7 +13,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
id="q"
type="search"
name="q"
placeholder="Search reviews, finds, lists, posts…"
placeholder="Search reviews, finds, bundles, posts…"
aria-label="Search"
autofocus
/>
@@ -29,7 +29,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
const results = document.getElementById('results') as HTMLDivElement;
type Entry = {
type: 'review' | 'find' | 'list' | 'post';
type: 'review' | 'find' | 'bundle' | 'post';
url: string;
title: string;
subtitle: string;
@@ -42,7 +42,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
const typeLabels: Record<Entry['type'], string> = {
review: 'Review',
find: 'Find',
list: 'List',
bundle: 'Bundle',
post: 'Post',
};
+10 -10
View File
@@ -1,10 +1,10 @@
import { getCollection } from 'astro:content';
export async function GET() {
const [reviews, finds, lists, posts] = await Promise.all([
const [reviews, finds, bundles, posts] = await Promise.all([
getCollection('reviews'),
getCollection('find'),
getCollection('finds'),
getCollection('bundles'),
getCollection('posts'),
]);
@@ -28,14 +28,14 @@ export async function GET() {
blurb: f.data.description ?? '',
source: f.data.source,
})),
...lists.map((l) => ({
type: 'list',
url: `/finds/${l.id}/`,
title: l.data.title,
subtitle: l.data.blurb ?? '',
date: l.data.date.toISOString(),
tags: l.data.tags ?? [],
blurb: l.data.description ?? '',
...bundles.map((b) => ({
type: 'bundle',
url: `/bundles/${b.id}/`,
title: b.data.title,
subtitle: b.data.blurb ?? '',
date: b.data.date.toISOString(),
tags: b.data.tags ?? [],
blurb: b.data.description ?? '',
})),
...posts.map((p) => ({
type: 'post',