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
+129
View File
@@ -0,0 +1,129 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro';
import FindCard from '../../components/FindCard.astro';
import { resolveFinds } from '../../lib/find-items';
import {
buildOgImage,
buildBreadcrumbs,
buildItemList,
PERSON_AUTHOR,
absoluteUrl,
} from '../../lib/seo';
export async function getStaticPaths() {
const bundles = await getCollection('bundles');
return bundles.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
const items = await resolveFinds(post.data.items);
const description =
post.data.description ?? post.data.blurb ?? `A collection: ${post.data.title}.`;
const ogImage = buildOgImage(
items[0]?.data.promotedTo,
'bundles',
`${post.data.title} cover image`
);
const articleSchema: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Article',
'@id': absoluteUrl(`/bundles/${post.id}/#article`),
headline: post.data.title,
description,
url: absoluteUrl(`/bundles/${post.id}/`),
datePublished: post.data.date.toISOString(),
author: PERSON_AUTHOR,
image: ogImage.url,
inLanguage: 'en-US',
};
const itemList = buildItemList(
items.map((it) => ({ url: `/find/${it.id}/`, name: it.data.name })),
post.data.title
);
const breadcrumbs = buildBreadcrumbs([
{ name: 'Home', url: '/' },
{ name: post.data.title, url: `/bundles/${post.id}/` },
]);
---
<BaseLayout
title={post.data.title}
description={description}
subheader={post.data.title}
subheaderVariant="headline"
lastUpdated={post.data.date}
ogType="article"
ogImage={ogImage}
publishedTime={post.data.date}
articleTags={post.data.tags}
jsonLd={[articleSchema, itemList, breadcrumbs]}
>
<article>
<div class="body">
<Content />
</div>
<Masonry>
{items.map((item) => <FindCard item={item} />)}
</Masonry>
{(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>
<style>
.body {
margin-bottom: 1.75rem;
max-width: 42rem;
}
.body :global(p) { margin: 0 0 1rem; }
.body :global(blockquote) {
margin: 1.25rem 0;
padding: 0.15rem 0 0.15rem 1rem;
border-left: 3px solid var(--accent);
color: var(--fg);
font-style: normal;
}
.body :global(blockquote p) { margin: 0; }
.back { margin-top: 2rem; font-size: 0.9rem; }
</style>
</BaseLayout>
+123
View File
@@ -0,0 +1,123 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro';
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import { bundleMosaic } from '../../lib/bundle-mosaic';
const bundles = (await getCollection('bundles')).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
const lastUpdated = bundles[0]?.data.date;
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
---
<BaseLayout
title="Bundles"
description={`All ${bundles.length} themed bundles on Unique.`}
subheader={`All ${bundles.length} themed bundles.`}
lastUpdated={lastUpdated}
noindex
>
<Masonry>
{bundles.map((b) => {
const mosaic = bundleMosaic(b.id);
return (
<article>
<a href={`/bundles/${b.id}/`} class="bundle-tile">
{mosaic && (
<div class="mosaic">
<Image
src={mosaic}
widths={[320, 480, 640, 960]}
sizes="(max-width: 520px) 92vw, (max-width: 880px) 45vw, 320px"
loading="lazy"
decoding="async"
alt=""
/>
</div>
)}
<div class="body">
<h2 class="title">{b.data.title}</h2>
{b.data.blurb && <p class="blurb">{b.data.blurb}</p>}
<p class="meta">
<time datetime={b.data.date.toISOString()}>{fmt.format(b.data.date)}</time>
<span class="dot" aria-hidden="true">·</span>
<span>{b.data.items.length} picks</span>
</p>
</div>
</a>
</article>
);
})}
</Masonry>
<style>
.bundle-tile {
display: block;
overflow: hidden;
border-radius: 8px;
border: 2px solid var(--rule);
color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.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);
}
.bundle-tile:hover .title { color: var(--accent); }
.mosaic {
display: block;
width: 100%;
}
.mosaic :global(img) {
display: block;
width: 100%;
height: auto;
}
.body {
padding: 1rem 1.15rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title {
margin: 0;
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-weight: 700;
font-size: 1.35rem;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.blurb {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.45;
}
.meta {
margin: 0.25rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.meta .dot { color: var(--rule); }
</style>
</BaseLayout>