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
@@ -1,39 +1,39 @@
--- ---
name: build-finds name: build-bundle
description: Draft a 'finds' superpost (editorial roundup) for unique.rzen.dev. Selects find items from src/content/find/, groups them under a theme, and writes a new file at src/content/finds/. Use when the user runs /build-finds, asks to "draft today's finds post", "build a roundup", or "make a catch". description: Draft a 'bundle' (themed editorial roundup of finds) for unique.rzen.dev. Selects find items from src/content/find/, groups them under a theme, and writes a new file at src/content/bundles/. Use when the user runs /build-bundle, asks to "draft today's bundle", "build a roundup", "make a bundle", or "make a catch".
disable-model-invocation: true disable-model-invocation: true
--- ---
# build-finds # build-bundle
Author one editorial superpost (collection: `finds`) that references existing find items. Each superpost has a theme, a short blurb, and an `items` array listing find slugs. The grid layout is rendered by the page template — this skill only writes the MDX. Author one editorial bundle (collection: `bundles`) that references existing find items. Each bundle has a theme, a short blurb, and an `items` array listing find slugs. The grid layout is rendered by the page template — this skill only writes the MDX.
Invoke from inside the `unique.rzen.dev` repo. Invoke from inside the `unique.rzen.dev` repo.
## Inputs ## Inputs
- `src/content/find/*.mdx` — the available pool of finds. - `src/content/find/*.mdx` — the available pool of finds.
- `src/content/finds/*.mdx` — existing superposts (used to detect already-referenced finds). - `src/content/bundles/*.mdx` — existing bundles (used to detect already-bundled finds).
- Optional argument: a theme phrase (e.g. "single-virtue tools", "things-disguised-as-other-things"). - Optional argument: a theme phrase (e.g. "single-virtue tools", "things-disguised-as-other-things").
- Optional argument: an explicit list of find slugs the user wants in the post. - Optional argument: an explicit list of find slugs the user wants in the bundle.
## Procedure ## Procedure
### 1. Determine candidate pool ### 1. Determine candidate pool
- **If the user provided explicit slugs (seed mode)** — usually the case when the user clicks the "Make a finds collection" button on `/find/`. Treat the slugs as a *seed*, not a final list. - **If the user provided explicit slugs (seed mode)** — usually the case when the user clicks the "Bundle this selection" button on `/find/`. Treat the slugs as a *seed*, not a final list.
1. Read each seed find's frontmatter (`tags`, `topics`, `subtitle`, `description`) and body. 1. Read each seed find's frontmatter (`tags`, `topics`, `subtitle`, `description`) and body.
2. Infer the through-line — the smallest phrase that captures what these finds have in common (a virtue, a use, a feeling, a structural pattern). Lean specific. Good: *"single-virtue tools"*, *"things that pour clean"*, *"objects that exist for no reason except someone wanted them to"*, *"vintage-leaning everyday carry"*. Bad: *"useful things"*, *"cool stuff"*. 2. Infer the through-line — the smallest phrase that captures what these finds have in common (a virtue, a use, a feeling, a structural pattern). Lean specific. Good: *"single-virtue tools"*, *"things that pour clean"*, *"objects that exist for no reason except someone wanted them to"*, *"vintage-leaning everyday carry"*. Bad: *"useful things"*, *"cool stuff"*.
3. Compute the **unreferenced pool**: every find that does NOT appear in the `items[]` array of any existing `src/content/finds/*.mdx` superpost. The seed slugs may or may not be in this pool — they're the seed regardless. 3. Compute the **unbundled pool**: every find that does NOT appear in the `items[]` array of any existing `src/content/bundles/*.mdx` bundle. The seed slugs may or may not be in this pool — they're the seed regardless.
4. From the unreferenced pool, find every entry that fits the inferred theme. Use frontmatter signals (tags, topics, subtitle) and body content. Don't reach: three weakly-fitting items is worse than zero. If a candidate's fit feels strained, drop it. 4. From the unbundled pool, find every entry that fits the inferred theme. Use frontmatter signals (tags, topics, subtitle) and body content. Don't reach: three weakly-fitting items is worse than zero. If a candidate's fit feels strained, drop it.
5. Combine `seed + qualifying unreferenced matches`, deduped. Cap at **8 total** (per sizing notes below). If the combined list exceeds 8, drop the weakest fits — never drop a seed slug; if the seed itself is more than 8, ask the user which to drop. 5. Combine `seed + qualifying unbundled matches`, deduped. Cap at **8 total** (per sizing notes below). If the combined list exceeds 8, drop the weakest fits — never drop a seed slug; if the seed itself is more than 8, ask the user which to drop.
6. Produce one cluster (theme phrase + final items list). Continue to step 2 to confirm before writing. 6. Produce one cluster (theme phrase + final items list). Continue to step 2 to confirm before writing.
- **If the user provided a theme phrase only:** scan all finds, surface the 812 that best fit the theme. - **If the user provided a theme phrase only:** scan all finds, surface the 812 that best fit the theme.
- **If neither:** scan recent (last ~14 days) finds that are not yet referenced by any existing superpost. From the unreferenced pool, propose 13 thematic clusters of 510 finds each, with a candidate theme phrase per cluster. - **If neither:** scan recent (last ~14 days) finds that are not yet referenced by any existing bundle. From the unbundled pool, propose 13 thematic clusters of 510 finds each, with a candidate theme phrase per cluster.
Re-use is allowed — a find can appear in multiple superposts under different themes. But by default, prefer surfacing **unreferenced** finds first; only reach for re-uses when the theme genuinely calls for them. Re-use is allowed — a find can appear in multiple bundles under different themes. But by default, prefer surfacing **unbundled** finds first; only reach for re-uses when the theme genuinely calls for them.
### 2. Confirm the cluster ### 2. Confirm the cluster
@@ -48,7 +48,7 @@ Seed (your selection):
- <slug>: <name> - <slug>: <name>
- ... - ...
Expansion (unreferenced finds matching the theme): Expansion (unbundled finds matching the theme):
- <slug>: <name> - <slug>: <name>
- ... - ...
@@ -69,9 +69,9 @@ Cluster B — "<theme phrase>"
Wait for the user to confirm, refine the theme phrase, or drop/swap items. Don't write anything until confirmed. Wait for the user to confirm, refine the theme phrase, or drop/swap items. Don't write anything until confirmed.
### 3. Write the superpost ### 3. Write the bundle
File: `src/content/finds/<YYYY-MM-DD>-<theme-slug>.mdx` (theme-slug = kebab-case of the theme phrase, lowercase, ~30 chars max). File: `src/content/bundles/<YYYY-MM-DD>-<theme-slug>/index.mdx` (theme-slug = kebab-case of the theme phrase, lowercase, ~30 chars max).
Frontmatter: Frontmatter:
@@ -81,7 +81,7 @@ title: "<Theme phrased as a noun phrase, max ~60 chars>"
date: YYYY-MM-DD date: YYYY-MM-DD
tags: [daily-finds, <topic-tag-1>, <topic-tag-2>] tags: [daily-finds, <topic-tag-1>, <topic-tag-2>]
description: "<One-line description for meta tags. Reuse the title if nothing better.>" description: "<One-line description for meta tags. Reuse the title if nothing better.>"
blurb: "<One short editorial sentence; appears as the post's secondary line in the home feed.>" blurb: "<One short editorial sentence; appears as the bundle's secondary line in the home feed.>"
items: items:
- <slug> - <slug>
- <slug> - <slug>
@@ -99,15 +99,15 @@ Example body:
### 4. Verify ### 4. Verify
- Confirm every slug in `items[]` exists at `src/content/find/<slug>.mdx`. If any is missing, abort and report — never reference a non-existent find. - Confirm every slug in `items[]` exists at `src/content/find/<slug>/index.mdx`. If any is missing, abort and report — never reference a non-existent find.
- Run `npm run build` to confirm the new superpost compiles. - Run `npm run build` to confirm the new bundle compiles.
### 5. Update the log ### 5. Update the log
Append to `daily-finds.log.md`: Append to `daily-finds.log.md`:
``` ```
## YYYY-MM-DD — superpost: <theme> ## YYYY-MM-DD — bundle: <theme>
Slug: <date>-<theme-slug> Slug: <date>-<theme-slug>
Items referenced: Items referenced:
@@ -118,7 +118,7 @@ Items referenced:
## Notes ## Notes
- Default to 5 finds per superpost. Use 3 for tighter themes, up to 8 if the theme really supports it. Beyond 8 the grid stops being scannable. - Default to 5 finds per bundle. Use 3 for tighter themes, up to 8 if the theme really supports it. Beyond 8 the grid stops being scannable.
- The body is for the audience, not the user. Never write "pick one", "candidates", "today's pick" — those are internal terms. - The body is for the audience, not the user. Never write "pick one", "candidates", "today's pick" — those are internal terms.
- Never edit existing find files when building a superpost. The find body and metadata are stable; the superpost only references them. - Never edit existing find files when building a bundle. The find body and metadata are stable; the bundle only references them.
- If a find that's referenced has been graduated to a review (its frontmatter has `promotedTo`), it can still appear in the superpost — the grid card simply links to the find page, which itself links forward to the review. - If a find that's referenced has been graduated to a review (its frontmatter has `promotedTo`), it can still appear in the bundle — the grid card simply links to the find page, which itself links forward to the review.
+23 -3
View File
@@ -72,14 +72,34 @@ Edit `src/content/find/<slug>.mdx` frontmatter: add `promotedTo: <review-slug>`.
(Slug usually matches the find's slug, so `promotedTo` and the find's filename match.) (Slug usually matches the find's slug, so `promotedTo` and the find's filename match.)
### 6. Verify ### 6. Register the hero in `src/lib/hero.ts` (REQUIRED if a hero asset exists)
This is the step that has bitten us repeatedly. **The home grid and category cards do NOT auto-discover review hero assets** — they only show a hero if the review's slug is a key in the `heroes` record exported by `src/lib/hero.ts`. Skipping this step ships a review whose grid card is empty.
For a review with a static image hero (`hero.png`, `hero.jpg`, `hero.webp`):
1. Add an `import` line near the top, alphabetically grouped with the other image imports. The variable name is the slug in camelCase. Example for slug `tablepro`:
```ts
import tablepro from '../content/reviews/tablepro/hero.png';
```
2. Add an entry to the `heroes` record, alphabetically by key:
```ts
tablepro: { kind: 'image', src: tablepro },
```
For a review with a video hero (`.mp4`), put the import in the second (video) import group and the record entry uses `kind: 'video'`. Match the existing pattern.
If the review legitimately has no hero asset (rare — e.g. text-only reviews like `snewpapers-newspaper-archive`, `sindre-sorhus-older-mac-apps`), explicitly note it in the log entry from step 8 ("no hero asset — text-only review") so the omission is intentional and traceable.
### 7. Verify
- Run `npm run build`. Confirm: - Run `npm run build`. Confirm:
- `/reviews/<slug>/` exists, shows the source badge and "← First surfaced…" backlink to the find page. - `/reviews/<slug>/` exists, shows the source badge and "← First surfaced…" backlink to the find page.
- `/find/<slug>/` shows the "→ Read the full review" callout pointing to the review. - `/find/<slug>/` shows the "→ Read the full review" callout pointing to the review.
- Any superpost that references the find still renders fine; the find card on the grid remains. - Any bundle that references the find still renders fine; the find card on the grid remains.
- Visit `/grid/` (or `/`) and confirm the new review's tile renders with its hero — not blank. **If the tile is empty, step 6 was skipped.**
### 7. Update the log ### 8. Update the log
Append to `daily-finds.log.md`: Append to `daily-finds.log.md`:
@@ -1,19 +1,19 @@
--- ---
name: cluster-finds name: cluster-bundles
description: Batch-cluster every existing find item in src/content/find/ into 4-7 thematic 'finds' superposts. Use when the user wants to bring the find pool up to date all at once — ("re-cluster the finds", "build superposts for everything", "spread the new finds across themed posts"). For drafting one superpost from a single theme, use build-finds instead. description: Batch-cluster every existing find item in src/content/find/ into 4-7 thematic bundles. Use when the user wants to bring the find pool up to date all at once — ("re-cluster the finds", "build bundles for everything", "spread the new finds across themed bundles"). For drafting one bundle from a single theme, use build-bundle instead.
disable-model-invocation: true disable-model-invocation: true
--- ---
# cluster-finds # cluster-bundles
Bulk version of `build-finds`. Reads the entire `src/content/find/` pool, partitions it into themed clusters, and writes one `src/content/finds/<date>-<slug>.mdx` superpost per cluster. The audience-facing site treats `finds` as the primary discovery vehicle for individual `find` items, so the goal is *coverage*: by the end of the run, almost every find that deserves a home should have one. Bulk version of `build-bundle`. Reads the entire `src/content/find/` pool, partitions it into themed clusters, and writes one `src/content/bundles/<date>-<slug>/index.mdx` bundle per cluster. The audience-facing site treats `bundles` as the primary discovery vehicle for individual `find` items, so the goal is *coverage*: by the end of the run, almost every find that deserves a home should have one.
Invoke from inside the `unique.rzen.dev` repo. Invoke from inside the `unique.rzen.dev` repo.
## Inputs ## Inputs
- `src/content/find/*.mdx` — the full pool to cluster. - `src/content/find/*.mdx` — the full pool to cluster.
- `src/content/finds/*.mdx` — already-written superposts. Their `items[]` lists are the prior clustering — read these to know which finds already have a home and what themes have been used. - `src/content/bundles/*.mdx` — already-written bundles. Their `items[]` lists are the prior clustering — read these to know which finds already have a home and what themes have been used.
- Optional argument: a number of clusters to target (default: model's call, usually 4-7). - Optional argument: a number of clusters to target (default: model's call, usually 4-7).
- Optional argument: a list of theme phrases to seed clustering around. - Optional argument: a list of theme phrases to seed clustering around.
@@ -23,7 +23,7 @@ Invoke from inside the `unique.rzen.dev` repo.
Read the frontmatter and body of every find. Note the `name`, `subtitle`, `topics`, `tags`, and the body's stated *virtue* (the specific thing that makes the item delightful). Tags and topics are hints, not the answer — the body usually tells you what cluster a find actually belongs to. Read the frontmatter and body of every find. Note the `name`, `subtitle`, `topics`, `tags`, and the body's stated *virtue* (the specific thing that makes the item delightful). Tags and topics are hints, not the answer — the body usually tells you what cluster a find actually belongs to.
Also read every existing finds file's `items[]` array — these are pre-clustered and should not be re-clustered into a new redundant theme. They can still be *re-used* in a new cluster if the new theme genuinely calls for them (see overlap rules). Also read every existing bundle's `items[]` array — these are pre-clustered and should not be re-clustered into a new redundant theme. They can still be *re-used* in a new cluster if the new theme genuinely calls for them (see overlap rules).
### 2. Partition into clusters ### 2. Partition into clusters
@@ -31,13 +31,13 @@ Aim for 4-7 clusters. Each cluster should:
- Hang off **one specific editorial angle**, phrasable in 4-8 words ("Pocket carry", "The morning kitchen ritual", "Travel as a kind of attention"). Avoid generic buckets ("Tools", "Apps") — those are tag pages. - Hang off **one specific editorial angle**, phrasable in 4-8 words ("Pocket carry", "The morning kitchen ritual", "Travel as a kind of attention"). Avoid generic buckets ("Tools", "Apps") — those are tag pages.
- Hold **3-8 items**. Below 3 the grid feels thin; above 8 it stops being scannable. - Hold **3-8 items**. Below 3 the grid feels thin; above 8 it stops being scannable.
- Be **distinct from existing finds posts** in angle, even if a couple of items repeat. - Be **distinct from existing bundles** in angle, even if a couple of items repeat.
Coverage check: after partitioning, every find should appear in at least one cluster (existing or new). If a find genuinely fits no theme, leave it uncovered and report it — don't pad a cluster with a misfit. Coverage check: after partitioning, every find should appear in at least one cluster (existing or new). If a find genuinely fits no theme, leave it uncovered and report it — don't pad a cluster with a misfit.
### 3. Overlap rules ### 3. Overlap rules
Re-use is allowed but rationed. Set a soft budget: at most ~20% of items across all new posts may be repeats from existing posts or from other new posts in this same run. Concretely, for a 25-item pool, that's ~5 reuses total. Track repeats as you draft. Re-use is allowed but rationed. Set a soft budget: at most ~20% of items across all new bundles may be repeats from existing bundles or from other new bundles in this same run. Concretely, for a 25-item pool, that's ~5 reuses total. Track repeats as you draft.
When choosing which items to repeat: only repeat a find when the second theme genuinely illuminates a different facet of it. "It also kind of fits over here" is not enough. When choosing which items to repeat: only repeat a find when the second theme genuinely illuminates a different facet of it. "It also kind of fits over here" is not enough.
@@ -52,15 +52,15 @@ Cluster A — "<theme>" (N items)
Cluster B — "<theme>" (N items) Cluster B — "<theme>" (N items)
... ...
Overlap with existing posts: <slug> appears in <existing-post> + <new cluster>; ... Overlap with existing bundles: <slug> appears in <existing-bundle> + <new cluster>; ...
Uncovered finds: <list, or "none"> Uncovered finds: <list, or "none">
``` ```
Wait for the user to confirm, refine, swap, or merge clusters. Do not write files until they sign off. Wait for the user to confirm, refine, swap, or merge clusters. Do not write files until they sign off.
### 5. Write each superpost ### 5. Write each bundle
Same file format as `build-finds`. For each cluster, write `src/content/finds/<YYYY-MM-DD>-<theme-slug>.mdx`: Same file format as `build-bundle`. For each cluster, write `src/content/bundles/<YYYY-MM-DD>-<theme-slug>/index.mdx`:
```yaml ```yaml
--- ---
@@ -75,19 +75,19 @@ items:
- ... - ...
--- ---
> <One audience-facing sentence or two that names what links these finds. Vary the opening — don't reuse 'Today's catch' for every post in the run. Read like a magazine standfirst, not a checklist.> > <One audience-facing sentence or two that names what links these finds. Vary the opening — don't reuse 'Today's catch' for every bundle in the run. Read like a magazine standfirst, not a checklist.>
``` ```
Body rules (same as `build-finds`): Body rules (same as `build-bundle`):
- 1-2 sentence intro paragraph in a blockquote. No headings, no list items, no numbered candidates — the grid handles the items. - 1-2 sentence intro paragraph in a blockquote. No headings, no list items, no numbered candidates — the grid handles the items.
- Audience-facing language. Never write "today's pick", "candidates", "winners". - Audience-facing language. Never write "today's pick", "candidates", "winners".
- Vary the opening across the batch. With 4-7 posts going out the same day, repeating "Today's catch" five times feels mechanical. - Vary the opening across the batch. With 4-7 bundles going out the same day, repeating "Today's catch" five times feels mechanical.
### 6. Verify ### 6. Verify
- Every slug in every `items[]` must exist at `src/content/find/<slug>.mdx`. Confirm before declaring done. - Every slug in every `items[]` must exist at `src/content/find/<slug>/index.mdx`. Confirm before declaring done.
- Run `npm run build`. If any superpost fails to compile, fix and re-run. - Run `npm run build`. If any bundle fails to compile, fix and re-run.
### 7. Update the log ### 7. Update the log
@@ -96,11 +96,11 @@ Append to `daily-finds.log.md`:
``` ```
## YYYY-MM-DD — cluster run ## YYYY-MM-DD — cluster run
Wrote <N> superposts from a pool of <M> finds. Wrote <N> bundles from a pool of <M> finds.
Reuses across posts: <count> (budget: ~20%). Reuses across bundles: <count> (budget: ~20%).
Uncovered finds: <list, or "none">. Uncovered finds: <list, or "none">.
Posts created: Bundles created:
- <date>-<slug-A>: <title> (N items) - <date>-<slug-A>: <title> (N items)
- <date>-<slug-B>: <title> (N items) - <date>-<slug-B>: <title> (N items)
- ... - ...
@@ -108,7 +108,7 @@ Posts created:
## Notes ## Notes
- This skill complements, doesn't replace, `build-finds`. After a cluster run, `build-finds` is still the right tool for ad-hoc, single-theme posts on top of new captures. - This skill complements, doesn't replace, `build-bundle`. After a cluster run, `build-bundle` is still the right tool for ad-hoc, single-theme bundles on top of new captures.
- If two themes you're considering have heavy overlap (≥50% same items), they're really one theme — merge them and pick the stronger framing. - If two themes you're considering have heavy overlap (≥50% same items), they're really one theme — merge them and pick the stronger framing.
- Date all posts with the run date, not staggered — these are siblings written together, and the home feed will sort them naturally by `title`. - Date all bundles with the run date, not staggered — these are siblings written together, and the home feed will sort them naturally by `title`.
- Don't edit existing finds posts when running this. If an existing post overlaps with a new cluster you're proposing, either keep the new cluster (overlap-tolerated) or drop it from the plan; do not retroactively rewrite history. - Don't edit existing bundles when running this. If an existing bundle overlaps with a new cluster you're proposing, either keep the new cluster (overlap-tolerated) or drop it from the plan; do not retroactively rewrite history.
+3 -3
View File
@@ -1,12 +1,12 @@
--- ---
name: daily-finds name: daily-finds
description: Capture-only daily sweep for unique.rzen.dev. Scans every source in sources.json, writes one find file per qualifying item to src/content/find/, and nominates any new sources cited in the items it reads (plus optionally via WebSearch). Does NOT pick a winner, write a superpost, or address the user/audience. Picking happens via /build-finds and /build-review. Use when the user asks for "today's finds", "morning sweep", "capture", or runs /daily-finds. description: Capture-only daily sweep for unique.rzen.dev. Scans every source in sources.json, writes one find file per qualifying item to src/content/find/, and nominates any new sources cited in the items it reads (plus optionally via WebSearch). Does NOT pick a winner, write a bundle, or address the user/audience. Picking happens via /build-bundle and /build-review. Use when the user asks for "today's finds", "morning sweep", "capture", or runs /daily-finds.
disable-model-invocation: true disable-model-invocation: true
--- ---
# daily-finds # daily-finds
Pure capture: every item that passes the bar becomes a `find` file. No daily cap, no theme detection, no audience-facing post. Whether any of these finds appears in a `finds` superpost or graduates to a review is a separate editorial decision. Pure capture: every item that passes the bar becomes a `find` file. No daily cap, no theme detection, no audience-facing post. Whether any of these finds appears in a `bundle` or graduates to a review is a separate editorial decision.
Invoke from inside the `unique.rzen.dev` repo. Invoke from inside the `unique.rzen.dev` repo.
@@ -108,7 +108,7 @@ Short summary in chat:
``` ```
Captured <N> finds across <M> productive sources today. Captured <N> finds across <M> productive sources today.
Blocked: <K> sources. New source nominees: <count>. Blocked: <K> sources. New source nominees: <count>.
Run /build-finds to draft a superpost from the recent pool, or /build-review <slug> to graduate one to a review. Run /build-bundle to draft a bundle from the recent pool, or /build-review <slug> to graduate one to a review.
``` ```
That's it. No theme, no candidates list, no "pick a winner." That's it. No theme, no candidates list, no "pick a winner."
+1
View File
@@ -2,6 +2,7 @@
## May 2026 ## May 2026
- Rename the "finds" superpost concept to **bundle**. The `finds` collection becomes `bundles` (`src/content/bundles/`), URLs move from `/finds/<slug>/` to `/bundles/<slug>/`, and the home/footer/search labels switch from "List" to "Bundle". Internal renames: `findListMosaic``bundleMosaic`, `FindsPost``Bundle`, `findsReferencing``bundlesReferencing`, `Entry.type 'finds'``'bundle'`, `OgFallback 'finds'``'bundles'`. Build script and generated mosaic dir follow (`scripts/build-bundle-mosaics.mjs`, `src/generated/bundle-mosaics/`). Skills `build-finds` and `cluster-finds` renamed to `build-bundle` and `cluster-bundles`. The `find` collection (individual items) is unchanged
- Add Cloudflare Web Analytics: privacy-preserving, cookieless beacon injected into `BaseLayout.astro` so per-page traffic, referrers, and Core Web Vitals show up in the Cloudflare dashboard scoped to `unique.rzen.dev`. Privacy statement on `/legal/` updated to disclose the beacon - Add Cloudflare Web Analytics: privacy-preserving, cookieless beacon injected into `BaseLayout.astro` so per-page traffic, referrers, and Core Web Vitals show up in the Cloudflare dashboard scoped to `unique.rzen.dev`. Privacy statement on `/legal/` updated to disclose the beacon
- Strengthen the "Use of the site" section on `/legal/`: explicit "inclusion isn't endorsement" + no warranty on safety/quality/durability/fit/continued-existence + no responsibility for what happens after click-through. Tone kept plain, not legalese - Strengthen the "Use of the site" section on `/legal/`: explicit "inclusion isn't endorsement" + no warranty on safety/quality/durability/fit/continued-existence + no responsibility for what happens after click-through. Tone kept plain, not legalese
- Colocate every collection's articles with their media: each entry is now a folder `<slug>/index.mdx` with sibling media files. Applied to `reviews/`, `find/` (104 articles + 89 heroes auto-resolved by `src/lib/find-hero.ts` via `import.meta.glob('../content/find/*/hero.{...}')`), `finds/`, and `posts/`. Slug derivation handled by a shared `stripIndex` helper on each glob loader; URLs unchanged across the entire site. MDX paths shortened from `../../assets/reviews/<slug>/<file>` to `./<file>`. `src/assets/` removed entirely - Colocate every collection's articles with their media: each entry is now a folder `<slug>/index.mdx` with sibling media files. Applied to `reviews/`, `find/` (104 articles + 89 heroes auto-resolved by `src/lib/find-hero.ts` via `import.meta.glob('../content/find/*/hero.{...}')`), `finds/`, and `posts/`. Slug derivation handled by a shared `stripIndex` helper on each glob loader; URLs unchanged across the entire site. MDX paths shortened from `../../assets/reviews/<slug>/<file>` to `./<file>`. `src/assets/` removed entirely
+8 -8
View File
@@ -6,14 +6,14 @@ Built with [Astro](https://astro.build) + MDX.
## Key features ## Key features
- **Four-collection pipeline**: `find` (captured items, hidden from indexes), `finds` (editorial superposts that reference find slugs in a grid), `reviews` (full-length, sometimes graduated from a find), `posts` (generic essays) - **Four-collection pipeline**: `find` (captured items, hidden from indexes), `bundles` (editorial roundups that reference find slugs in a grid), `reviews` (full-length, sometimes graduated from a find), `posts` (generic essays)
- **Daily capture skills** under `.claude/skills/`: `daily-finds` (sweeps every source in `sources.json`, writes one find file per qualifying item — capture-only, no editorial selection); `build-finds` (drafts a thematic superpost from the find pool); `build-review` (graduates a find to a full review, linking the two via `fromFind` / `promotedTo`) - **Daily capture skills** under `.claude/skills/`: `daily-finds` (sweeps every source in `sources.json`, writes one find file per qualifying item — capture-only, no editorial selection); `build-bundle` (drafts a thematic bundle from the find pool); `cluster-bundles` (batch-clusters the entire find pool into multiple bundles); `build-review` (graduates a find to a full review, linking the two via `fromFind` / `promotedTo`)
- **Sources catalog** at `sources.json` with per-source fetch strategy (`webfetch` / `curl` / `skip`) 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 (`webfetch` / `curl` / `skip`) 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 superposts 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 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
- **Backlinks** computed at build time: each find page lists every superpost 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 + finds (superposts) + 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 — finds excluded by design
@@ -113,13 +113,13 @@ description: "Optional <meta> tag override."
Body in markdown / MDX. Body in markdown / MDX.
``` ```
The folder name becomes the URL at `/posts/<slug>/`. Drop any media (images, screenshots) next to `index.mdx` and reference them as `./image.png`. Same convention applies to the `find/` and `finds/` collections — each entry is a folder with `index.mdx` and any sibling media. The folder name becomes the URL at `/posts/<slug>/`. Drop any media (images, screenshots) next to `index.mdx` and reference them as `./image.png`. Same convention applies to the `find/` and `bundles/` collections — each entry is a folder with `index.mdx` and any sibling media.
## Project layout ## Project layout
``` ```
src/ src/
content.config.ts # collection schemas (reviews, posts, find, finds) content.config.ts # collection schemas (reviews, posts, find, bundles)
content/ content/
reviews/<slug>/ reviews/<slug>/
index.mdx # article + frontmatter index.mdx # article + frontmatter
@@ -128,7 +128,7 @@ src/
find/<slug>/ find/<slug>/
index.mdx index.mdx
hero.{jpg,png,gif,webp} # auto-resolved by src/lib/find-hero.ts hero.{jpg,png,gif,webp} # auto-resolved by src/lib/find-hero.ts
finds/<slug>/index.mdx # editorial superposts (text-only today) bundles/<slug>/index.mdx # editorial roundups (text intro + grid)
posts/<slug>/index.mdx # essays posts/<slug>/index.mdx # essays
lib/ lib/
entries.ts # getAllEntries() — unified feed across collections entries.ts # getAllEntries() — unified feed across collections
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build:mosaics": "node scripts/build-list-mosaics.mjs", "build:mosaics": "node scripts/build-bundle-mosaics.mjs",
"predev": "npm run build:mosaics", "predev": "npm run build:mosaics",
"prestart": "npm run build:mosaics", "prestart": "npm run build:mosaics",
"prebuild": "npm run build:mosaics", "prebuild": "npm run build:mosaics",
@@ -1,5 +1,5 @@
// Build-time generator: for each list in src/content/finds, compose a // Build-time generator: for each bundle in src/content/bundles, compose a
// mosaic JPG from the item heroes available in that list. Skips work // mosaic JPG from the item heroes available in that bundle. Skips work
// when output is newer than every input. Layout depends on hero count: // when output is newer than every input. Layout depends on hero count:
// 0 -> no mosaic (file is removed if it exists) // 0 -> no mosaic (file is removed if it exists)
// 1 -> 1200x1200 single image // 1 -> 1200x1200 single image
@@ -15,9 +15,9 @@ import sharp from 'sharp';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..'); const ROOT = resolve(__dirname, '..');
const FINDS_DIR = join(ROOT, 'src/content/finds'); const BUNDLES_DIR = join(ROOT, 'src/content/bundles');
const FIND_DIR = join(ROOT, 'src/content/find'); const FIND_DIR = join(ROOT, 'src/content/find');
const OUT_DIR = join(ROOT, 'src/generated/list-mosaics'); const OUT_DIR = join(ROOT, 'src/generated/bundle-mosaics');
const TILE = 598; const TILE = 598;
const GAP = 4; const GAP = 4;
@@ -122,7 +122,7 @@ async function tryUnlink(path) {
async function main() { async function main() {
await mkdir(OUT_DIR, { recursive: true }); await mkdir(OUT_DIR, { recursive: true });
const listDirs = (await readdir(FINDS_DIR, { withFileTypes: true })) const bundleDirs = (await readdir(BUNDLES_DIR, { withFileTypes: true }))
.filter((d) => d.isDirectory()) .filter((d) => d.isDirectory())
.map((d) => d.name); .map((d) => d.name);
@@ -131,13 +131,13 @@ async function main() {
let removed = 0; let removed = 0;
let absent = 0; let absent = 0;
for (const listSlug of listDirs) { for (const bundleSlug of bundleDirs) {
const mdxPath = join(FINDS_DIR, listSlug, 'index.mdx'); const mdxPath = join(BUNDLES_DIR, bundleSlug, 'index.mdx');
const mdPath = join(FINDS_DIR, listSlug, 'index.md'); const mdPath = join(BUNDLES_DIR, bundleSlug, 'index.md');
const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null; const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null;
if (!sourcePath) continue; if (!sourcePath) continue;
const outPath = join(OUT_DIR, `${listSlug}.jpg`); const outPath = join(OUT_DIR, `${bundleSlug}.jpg`);
const fm = await readFrontmatter(sourcePath); const fm = await readFrontmatter(sourcePath);
const items = parseItems(fm); const items = parseItems(fm);
if (items.length === 0) { if (items.length === 0) {
@@ -165,10 +165,10 @@ async function main() {
await buildMosaic(heroPaths, outPath); await buildMosaic(heroPaths, outPath);
built++; built++;
console.log(` built ${listSlug}.jpg (${heroPaths.length}-up)`); console.log(` built ${bundleSlug}.jpg (${heroPaths.length}-up)`);
} }
console.log( console.log(
`list mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)` `bundle mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)`
); );
} }
+3 -3
View File
@@ -1,8 +1,8 @@
--- ---
import type { FindsPost } from '../lib/backlinks'; import type { Bundle } from '../lib/backlinks';
interface Props { interface Props {
posts: FindsPost[]; posts: Bundle[];
label?: string; label?: string;
} }
const { posts, label = 'Featured in' } = Astro.props; const { posts, label = 'Featured in' } = Astro.props;
@@ -15,7 +15,7 @@ const { posts, label = 'Featured in' } = Astro.props;
{posts.map((p, i) => ( {posts.map((p, i) => (
<> <>
{i > 0 && <span class="sep">·</span>} {i > 0 && <span class="sep">·</span>}
<a href={`/finds/${p.id}/`}>{p.data.title}</a> <a href={`/bundles/${p.id}/`}>{p.data.title}</a>
</> </>
))} ))}
</p> </p>
+4 -4
View File
@@ -135,7 +135,7 @@
let command = btn.dataset.command || ''; let command = btn.dataset.command || '';
let description = btn.dataset.confirm || ''; let description = btn.dataset.confirm || '';
if (btn.dataset.editAction === 'finds-collection') { if (btn.dataset.editAction === 'bundle-from-selection') {
const checked = Array.from( const checked = Array.from(
document.querySelectorAll<HTMLInputElement>('[data-find-checkbox]:checked') document.querySelectorAll<HTMLInputElement>('[data-find-checkbox]:checked')
); );
@@ -144,10 +144,10 @@
return; return;
} }
const slugs = checked.map((cb) => cb.dataset.slug).filter(Boolean).join(','); const slugs = checked.map((cb) => cb.dataset.slug).filter(Boolean).join(',');
command = `/build-finds ${slugs}`; command = `/build-bundle ${slugs}`;
description = `Build a finds collection from ${checked.length} selected find${ description = `Bundle ${checked.length} selected find${
checked.length === 1 ? '' : 's' checked.length === 1 ? '' : 's'
}.`; } under one theme.`;
} }
if (btn.dataset.editAction === 'tag-prompt') { if (btn.dataset.editAction === 'tag-prompt') {
+147
View File
@@ -0,0 +1,147 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import Masonry from './Masonry.astro';
import type { Entry } from '../lib/entries';
import { heroes } from '../lib/hero';
import { bundleMosaic } from '../lib/bundle-mosaic';
interface Props {
entries: Entry[];
}
const { entries } = Astro.props;
type Visual =
| { kind: 'image'; src: ImageMetadata }
| { kind: 'video'; src: string };
function visualFor(entry: Entry): Visual | undefined {
if (entry.type === 'review') return heroes[entry.id];
if (entry.type === 'bundle') {
const mosaic = bundleMosaic(entry.id);
return mosaic ? { kind: 'image', src: mosaic } : undefined;
}
return undefined;
}
---
<Masonry>
{
entries.map((entry) => {
const visual = visualFor(entry);
const isBundle = entry.type === 'bundle';
return (
<article>
<a
href={entry.href}
class:list={['tile', { 'text-only': !visual, 'is-bundle': isBundle }]}
>
{visual?.kind === 'image' && (
<Image
class="media"
src={visual.src}
widths={[320, 480, 640]}
sizes="(max-width: 520px) 92vw, (max-width: 880px) 45vw, 320px"
loading="lazy"
decoding="async"
alt={entry.primary}
/>
)}
{visual?.kind === 'video' && (
<video
class="media"
src={visual.src}
autoplay
loop
muted
playsinline
preload="none"
aria-label={entry.primary}
/>
)}
<div class="text">
{isBundle && <span class="eyebrow">Bundle</span>}
<span class="name">{entry.primary}</span>
{entry.secondary && <span class="subtitle">{entry.secondary}</span>}
</div>
</a>
</article>
);
})
}
</Masonry>
<style>
.tile {
display: block;
border-radius: 8px;
overflow: hidden;
border: 2px solid var(--rule);
background: var(--bg);
color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.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);
}
.tile:hover .name { color: var(--accent); }
.media {
display: block;
width: 100%;
height: auto;
background: var(--rule);
}
.text {
padding: 0.85rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
background: var(--card-fill);
}
.eyebrow {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 600;
}
.tile.is-bundle .name {
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.01em;
}
.name {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.subtitle {
color: var(--muted);
font-size: 0.92rem;
line-height: 1.4;
}
.tile.text-only {
background: linear-gradient(155deg, var(--rule), transparent);
}
.tile.text-only .text {
padding: 1.5rem 1.25rem;
}
.tile.text-only .name {
font-size: 1.25rem;
margin-bottom: 0.3rem;
}
.tile.text-only .subtitle {
font-size: 1rem;
}
</style>
+4 -4
View File
@@ -60,16 +60,16 @@ const find = defineCollection({
}), }),
}); });
const finds = defineCollection({ const bundles = defineCollection({
loader: glob({ loader: glob({
pattern: '**/index.mdx', pattern: '**/index.mdx',
base: './src/content/finds', base: './src/content/bundles',
generateId: stripIndex, generateId: stripIndex,
}), }),
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
date: z.coerce.date(), date: z.coerce.date(),
category: z.string().default('finds'), category: z.string().default('bundles'),
tags: z.array(z.string()).default([]), tags: z.array(z.string()).default([]),
description: z.string().optional(), description: z.string().optional(),
blurb: z.string().optional(), blurb: z.string().optional(),
@@ -77,4 +77,4 @@ const finds = defineCollection({
}), }),
}); });
export const collections = { reviews, posts, find, finds }; export const collections = { reviews, posts, find, bundles };
+115 -1
View File
@@ -137,7 +137,7 @@ const year = new Date().getFullYear();
<ul> <ul>
<li><a href="/grid/">Reviews</a></li> <li><a href="/grid/">Reviews</a></li>
<li><a href="/find/">Finds</a></li> <li><a href="/find/">Finds</a></li>
<li><a href="/finds/">Lists</a></li> <li><a href="/bundles/">Bundles</a></li>
<li><a href="/blog/">Posts</a></li> <li><a href="/blog/">Posts</a></li>
</ul> </ul>
</section> </section>
@@ -417,6 +417,120 @@ const year = new Date().getFullYear();
font-style: italic; font-style: italic;
} }
hr { border: 0; border-top: 1px solid var(--rule); margin: 2rem 0; } hr { border: 0; border-top: 1px solid var(--rule); margin: 2rem 0; }
/* External CTA — primary "go to source" link on /reviews/ and /find/.
Sits in the body region below the write-up, not in the colophon. */
.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; }
.external .aff-badge {
display: inline-block;
margin-left: 0.6rem;
padding: 0.15rem 0.5rem;
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;
}
/* Colophon — understated meta block at the foot of a post.
Used on /reviews/, /find/, /bundles/ pages. */
.colophon {
margin: 3rem 0 0;
padding-top: 1.5rem;
border-top: 1px solid var(--rule);
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.8rem;
line-height: 1.5;
color: var(--muted);
}
.colophon dl {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.55rem 1.25rem;
align-items: baseline;
}
.colophon dt {
margin: 0;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
line-height: 1.5;
color: var(--muted);
}
.colophon dd {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.colophon a {
color: var(--muted);
border-bottom: 1px solid var(--rule);
}
.colophon a:hover {
color: var(--accent);
border-bottom-color: currentColor;
text-decoration: none;
}
.colophon .colophon-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
}
.colophon .colophon-tags a { border-bottom: 0; }
.colophon time { color: var(--muted); }
.colophon .aff-badge {
display: inline-block;
margin-left: 0.45rem;
padding: 0.1rem 0.4rem;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
background: var(--rule);
border-radius: 999px;
vertical-align: middle;
}
.colophon-aff {
margin: 1rem 0 0;
padding-top: 0.85rem;
border-top: 1px dashed var(--rule);
font-size: 0.72rem;
font-style: italic;
color: var(--muted);
}
@media (max-width: 520px) {
.colophon dl {
grid-template-columns: 1fr;
gap: 0.15rem 0;
}
.colophon dt { margin-top: 0.6rem; }
.colophon dt:first-child { margin-top: 0; }
}
</style> </style>
{import.meta.env.DEV && <EditConfirm />} {import.meta.env.DEV && <EditConfirm />}
</body> </body>
+3 -3
View File
@@ -1,9 +1,9 @@
import { getCollection, type CollectionEntry } from 'astro:content'; import { getCollection, type CollectionEntry } from 'astro:content';
export type FindsPost = CollectionEntry<'finds'>; export type Bundle = CollectionEntry<'bundles'>;
export async function findsReferencing(slug: string): Promise<FindsPost[]> { export async function bundlesReferencing(slug: string): Promise<Bundle[]> {
const all = await getCollection('finds'); const all = await getCollection('bundles');
return all return all
.filter((p) => p.data.items.includes(slug)) .filter((p) => p.data.items.includes(slug))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
@@ -1,16 +1,16 @@
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
const modules = import.meta.glob<{ default: ImageMetadata }>( const modules = import.meta.glob<{ default: ImageMetadata }>(
'../generated/list-mosaics/*.jpg', '../generated/bundle-mosaics/*.jpg',
{ eager: true } { eager: true }
); );
const mosaics = new Map<string, ImageMetadata>(); const mosaics = new Map<string, ImageMetadata>();
for (const [path, mod] of Object.entries(modules)) { for (const [path, mod] of Object.entries(modules)) {
const match = path.match(/list-mosaics\/([^/]+)\.jpg$/); const match = path.match(/bundle-mosaics\/([^/]+)\.jpg$/);
if (match) mosaics.set(match[1], mod.default); if (match) mosaics.set(match[1], mod.default);
} }
export function findListMosaic(slug: string): ImageMetadata | undefined { export function bundleMosaic(slug: string): ImageMetadata | undefined {
return mosaics.get(slug); return mosaics.get(slug);
} }
+10 -10
View File
@@ -1,7 +1,7 @@
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
export type Entry = { export type Entry = {
type: 'review' | 'post' | 'finds'; type: 'review' | 'post' | 'bundle';
id: string; id: string;
href: string; href: string;
date: Date; date: Date;
@@ -14,7 +14,7 @@ export type Entry = {
export async function getAllEntries(): Promise<Entry[]> { export async function getAllEntries(): Promise<Entry[]> {
const reviews = await getCollection('reviews'); const reviews = await getCollection('reviews');
const posts = await getCollection('posts'); const posts = await getCollection('posts');
const findsPosts = await getCollection('finds'); const bundles = await getCollection('bundles');
const r: Entry[] = reviews.map((e) => ({ const r: Entry[] = reviews.map((e) => ({
type: 'review', type: 'review',
@@ -37,10 +37,10 @@ export async function getAllEntries(): Promise<Entry[]> {
primary: e.data.title, primary: e.data.title,
})); }));
const f: Entry[] = findsPosts.map((e) => ({ const b: Entry[] = bundles.map((e) => ({
type: 'finds', type: 'bundle',
id: e.id, id: e.id,
href: `/finds/${e.id}/`, href: `/bundles/${e.id}/`,
date: e.data.date, date: e.data.date,
category: e.data.category, category: e.data.category,
tags: e.data.tags, tags: e.data.tags,
@@ -48,7 +48,7 @@ export async function getAllEntries(): Promise<Entry[]> {
secondary: e.data.blurb, secondary: e.data.blurb,
})); }));
return [...r, ...p, ...f].sort((a, b) => b.date.valueOf() - a.date.valueOf()); return [...r, ...p, ...b].sort((a, b) => b.date.valueOf() - a.date.valueOf());
} }
export type SiteStats = { export type SiteStats = {
@@ -57,7 +57,7 @@ export type SiteStats = {
reviews: number; reviews: number;
finds: number; finds: number;
posts: number; posts: number;
lists: number; bundles: number;
}; };
let statsCache: Promise<SiteStats> | null = null; let statsCache: Promise<SiteStats> | null = null;
@@ -73,17 +73,17 @@ async function computeSiteStats(): Promise<SiteStats> {
const reviews = entries.filter((e) => e.type === 'review').length; const reviews = entries.filter((e) => e.type === 'review').length;
const finds = findItems.length; const finds = findItems.length;
const posts = entries.filter((e) => e.type === 'post').length; const posts = entries.filter((e) => e.type === 'post').length;
const lists = entries.filter((e) => e.type === 'finds').length; const bundles = entries.filter((e) => e.type === 'bundle').length;
const allDates = [ const allDates = [
...entries.map((e) => e.date), ...entries.map((e) => e.date),
...findItems.map((f) => f.data.date), ...findItems.map((f) => f.data.date),
].sort((a, b) => b.valueOf() - a.valueOf()); ].sort((a, b) => b.valueOf() - a.valueOf());
return { return {
total: reviews + finds + posts + lists, total: reviews + finds + posts + bundles,
lastUpdated: allDates[0] ?? null, lastUpdated: allDates[0] ?? null,
reviews, reviews,
finds, finds,
posts, posts,
lists, bundles,
}; };
} }
+2 -2
View File
@@ -1,13 +1,13 @@
import { heroes } from './hero'; import { heroes } from './hero';
import type { OgImage } from '../components/SEO.astro'; import type { OgImage } from '../components/SEO.astro';
export type OgFallback = 'reviews' | 'finds' | 'posts' | 'site'; export type OgFallback = 'reviews' | 'bundles' | 'posts' | 'site';
const SITE_URL = 'https://unique.rzen.dev'; const SITE_URL = 'https://unique.rzen.dev';
const FALLBACK_PATHS: Record<OgFallback, string> = { const FALLBACK_PATHS: Record<OgFallback, string> = {
reviews: '/og/reviews.png', reviews: '/og/reviews.png',
finds: '/og/finds.png', bundles: '/og/bundles.png',
posts: '/og/posts.png', posts: '/og/posts.png',
site: '/og/default.png', site: '/og/default.png',
}; };
@@ -13,8 +13,8 @@ import {
} from '../../lib/seo'; } from '../../lib/seo';
export async function getStaticPaths() { export async function getStaticPaths() {
const finds = await getCollection('finds'); const bundles = await getCollection('bundles');
return finds.map((post) => ({ return bundles.map((post) => ({
params: { slug: post.id }, params: { slug: post.id },
props: { post }, props: { post },
})); }));
@@ -29,17 +29,17 @@ const description =
const ogImage = buildOgImage( const ogImage = buildOgImage(
items[0]?.data.promotedTo, items[0]?.data.promotedTo,
'finds', 'bundles',
`${post.data.title} cover image` `${post.data.title} cover image`
); );
const articleSchema: Record<string, unknown> = { const articleSchema: Record<string, unknown> = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Article', '@type': 'Article',
'@id': absoluteUrl(`/finds/${post.id}/#article`), '@id': absoluteUrl(`/bundles/${post.id}/#article`),
headline: post.data.title, headline: post.data.title,
description, description,
url: absoluteUrl(`/finds/${post.id}/`), url: absoluteUrl(`/bundles/${post.id}/`),
datePublished: post.data.date.toISOString(), datePublished: post.data.date.toISOString(),
author: PERSON_AUTHOR, author: PERSON_AUTHOR,
image: ogImage.url, image: ogImage.url,
@@ -53,7 +53,7 @@ const itemList = buildItemList(
const breadcrumbs = buildBreadcrumbs([ const breadcrumbs = buildBreadcrumbs([
{ name: 'Home', url: '/' }, { 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} />)} {items.map((item) => <FindCard item={item} />)}
</Masonry> </Masonry>
{ {(post.data.tags.length > 0 ||
post.data.tags.length > 0 && ( (post.data.category && post.data.category !== 'bundles')) && (
<footer class="tags"> <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) => ( {post.data.tags.map((tag) => (
<a href={`/tags/${tag}/`} class="tag">#{tag}</a> <a href={`/tags/${tag}/`}>#{tag}</a>
))} ))}
</footer> </dd>
) </>
} )}
</dl>
</aside>
)}
<p class="back"><a href="/">← back</a></p> <p class="back"><a href="/">← back</a></p>
</article> </article>
@@ -105,16 +124,6 @@ const breadcrumbs = buildBreadcrumbs([
font-style: normal; font-style: normal;
} }
.body :global(blockquote p) { margin: 0; } .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; } .back { margin-top: 2rem; font-size: 0.9rem; }
</style> </style>
</BaseLayout> </BaseLayout>
@@ -3,12 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro'; import Masonry from '../../components/Masonry.astro';
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import { getCollection } from 'astro:content'; 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() (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', { const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric', year: 'numeric',
@@ -19,18 +19,18 @@ const fmt = new Intl.DateTimeFormat('en-US', {
--- ---
<BaseLayout <BaseLayout
title="Lists" title="Bundles"
description={`All ${lists.length} themed lists on Unique.`} description={`All ${bundles.length} themed bundles on Unique.`}
subheader={`All ${lists.length} themed lists.`} subheader={`All ${bundles.length} themed bundles.`}
lastUpdated={lastUpdated} lastUpdated={lastUpdated}
noindex noindex
> >
<Masonry> <Masonry>
{lists.map((l) => { {bundles.map((b) => {
const mosaic = findListMosaic(l.id); const mosaic = bundleMosaic(b.id);
return ( return (
<article> <article>
<a href={`/finds/${l.id}/`} class="list-tile"> <a href={`/bundles/${b.id}/`} class="bundle-tile">
{mosaic && ( {mosaic && (
<div class="mosaic"> <div class="mosaic">
<Image <Image
@@ -44,12 +44,12 @@ const fmt = new Intl.DateTimeFormat('en-US', {
</div> </div>
)} )}
<div class="body"> <div class="body">
<h2 class="title">{l.data.title}</h2> <h2 class="title">{b.data.title}</h2>
{l.data.blurb && <p class="blurb">{l.data.blurb}</p>} {b.data.blurb && <p class="blurb">{b.data.blurb}</p>}
<p class="meta"> <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 class="dot" aria-hidden="true">·</span>
<span>{l.data.items.length} picks</span> <span>{b.data.items.length} picks</span>
</p> </p>
</div> </div>
</a> </a>
@@ -59,7 +59,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
</Masonry> </Masonry>
<style> <style>
.list-tile { .bundle-tile {
display: block; display: block;
overflow: hidden; overflow: hidden;
border-radius: 8px; border-radius: 8px;
@@ -67,12 +67,12 @@ const fmt = new Intl.DateTimeFormat('en-US', {
color: inherit; color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease; transition: box-shadow 0.18s ease, border-color 0.18s ease;
} }
.list-tile:hover { .bundle-tile:hover {
text-decoration: none; text-decoration: none;
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); 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); 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 { .mosaic {
display: block; display: block;
+20 -63
View File
@@ -2,10 +2,10 @@
import { getCollection, render } from 'astro:content'; import { getCollection, render } from 'astro:content';
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import SourceBadge from '../../components/SourceBadge.astro';
import Backlinks from '../../components/Backlinks.astro'; import Backlinks from '../../components/Backlinks.astro';
import { findsReferencing } from '../../lib/backlinks'; import { bundlesReferencing } from '../../lib/backlinks';
import { findHero } from '../../lib/find-hero'; import { findHero } from '../../lib/find-hero';
import { sourceSlug } from '../../lib/sources';
import EditAction from '../../components/EditAction.astro'; import EditAction from '../../components/EditAction.astro';
import { import {
resolveExternalLink, resolveExternalLink,
@@ -24,7 +24,7 @@ export async function getStaticPaths() {
const { item } = Astro.props; const { item } = Astro.props;
const { Content } = await render(item); const { Content } = await render(item);
const featuringPosts = await findsReferencing(item.id); const featuringPosts = await bundlesReferencing(item.id);
const hero = findHero(item.id); const hero = findHero(item.id);
const linkLabel = const linkLabel =
@@ -41,7 +41,7 @@ const canonicalOverride = item.data.promotedTo
const ogImage = buildOgImage( const ogImage = buildOgImage(
item.data.promotedTo, item.data.promotedTo,
'finds', 'bundles',
`${item.data.name} preview image` `${item.data.name} preview image`
); );
@@ -82,10 +82,6 @@ const breadcrumbs = buildBreadcrumbs([
jsonLd={[productSchema, breadcrumbs]} jsonLd={[productSchema, breadcrumbs]}
> >
<article> <article>
<header class="find-header">
<p class="source-line"><SourceBadge source={item.data.source} /></p>
</header>
{hero && ( {hero && (
<Image <Image
class="hero" class="hero"
@@ -138,24 +134,29 @@ const breadcrumbs = buildBreadcrumbs([
<Backlinks posts={featuringPosts} /> <Backlinks posts={featuringPosts} />
{ <aside class="colophon" aria-label="Colophon">
item.data.tags.length > 0 && ( <dl>
<footer class="tags"> <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) => ( {item.data.tags.map((tag) => (
<a href={`/tags/${tag}/`} class="tag">#{tag}</a> <a href={`/tags/${tag}/`}>#{tag}</a>
))} ))}
</footer> </dd>
) </>
} )}
</dl>
</aside>
<p class="back"><a href="/">← back</a></p> <p class="back"><a href="/">← back</a></p>
</article> </article>
<style> <style>
.find-header {
margin-bottom: 1.5rem;
}
.source-line { margin: 0; }
.hero { .hero {
display: block; display: block;
width: 100%; width: 100%;
@@ -168,40 +169,6 @@ const breadcrumbs = buildBreadcrumbs([
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
} }
.body :global(p) { margin: 0 0 1rem; } .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 { .promoted {
margin: 1.5rem 0 0; margin: 1.5rem 0 0;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
@@ -210,16 +177,6 @@ const breadcrumbs = buildBreadcrumbs([
font-size: 0.95rem; font-size: 0.95rem;
} }
.promoted a { font-weight: 600; } .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; } .back { margin-top: 2rem; font-size: 0.9rem; }
.edit-toolbar { .edit-toolbar {
margin: 2rem 0 0; margin: 2rem 0 0;
+7 -7
View File
@@ -35,14 +35,14 @@ const decorated = (await getAllFinds())
const finds = decorated.map((d) => d.item); const finds = decorated.map((d) => d.item);
const lastUpdated = finds[0]?.data.date; const lastUpdated = finds[0]?.data.date;
// Slugs referenced by any superpost in the `finds/` collection — dev-only filter. // Slugs referenced by any bundle — dev-only filter.
const inListSlugs = new Set( const bundledSlugs = new Set(
(await getCollection('finds')).flatMap((l) => l.data.items) (await getCollection('bundles')).flatMap((b) => b.data.items)
); );
const isJoke = (f: (typeof finds)[number]) => f.id.startsWith('joke-'); const isJoke = (f: (typeof finds)[number]) => f.id.startsWith('joke-');
const isQuote = (f: (typeof finds)[number]) => f.id.startsWith('quote-'); const isQuote = (f: (typeof finds)[number]) => f.id.startsWith('quote-');
const isUnused = (f: (typeof finds)[number]) => 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 unusedCount = finds.filter(isUnused).length;
const jokeCount = finds.filter(isJoke).length; const jokeCount = finds.filter(isJoke).length;
const quoteCount = finds.filter(isQuote).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 data-find-selected-count>0</span> selected
</span> </span>
<EditAction <EditAction
action="finds-collection" action="bundle-from-selection"
label="Make a finds collection" label="Bundle this selection"
confirm="This will draft a themed finds list (.mdx in src/content/finds/) from the selected items." confirm="This will draft a themed bundle (.mdx in src/content/bundles/) from the selected items."
/> />
</div> </div>
)} )}
+20 -20
View File
@@ -4,10 +4,10 @@ import { Image } from 'astro:assets';
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import Masonry from '../components/Masonry.astro'; import Masonry from '../components/Masonry.astro';
import { heroes } from '../lib/hero'; 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'; import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
const HERO_SLUG = 'monitorcontrol'; const HERO_SLUG = 'gum-shell';
const INITIAL_GRID = 12; const INITIAL_GRID = 12;
const BATCH_SIZE = 7; const BATCH_SIZE = 7;
@@ -22,23 +22,23 @@ type Tile =
hero: ReturnType<typeof getReviewHero>; hero: ReturnType<typeof getReviewHero>;
} }
| { | {
kind: 'list'; kind: 'bundle';
href: string; href: string;
id: string; id: string;
name: string; name: string;
subtitle: string | undefined; subtitle: string | undefined;
date: Date; date: Date;
itemCount: number; itemCount: number;
mosaic: ReturnType<typeof findListMosaic>; mosaic: ReturnType<typeof bundleMosaic>;
}; };
function getReviewHero(slug: string) { function getReviewHero(slug: string) {
return heroes[slug]; return heroes[slug];
} }
const [reviews, lists] = await Promise.all([ const [reviews, bundles] = await Promise.all([
getCollection('reviews'), getCollection('reviews'),
getCollection('finds'), getCollection('bundles'),
]); ]);
const reviewTiles: Tile[] = reviews.map((review) => ({ const reviewTiles: Tile[] = reviews.map((review) => ({
@@ -51,18 +51,18 @@ const reviewTiles: Tile[] = reviews.map((review) => ({
hero: getReviewHero(review.id), hero: getReviewHero(review.id),
})); }));
const listTiles: Tile[] = lists.map((list) => ({ const bundleTiles: Tile[] = bundles.map((bundle) => ({
kind: 'list' as const, kind: 'bundle' as const,
href: `/finds/${list.id}/`, href: `/bundles/${bundle.id}/`,
id: list.id, id: bundle.id,
name: list.data.title, name: bundle.data.title,
subtitle: list.data.blurb, subtitle: bundle.data.blurb,
date: list.data.date, date: bundle.data.date,
itemCount: list.data.items.length, itemCount: bundle.data.items.length,
mosaic: findListMosaic(list.id), mosaic: bundleMosaic(bundle.id),
})); }));
const tiles: Tile[] = [...reviewTiles, ...listTiles].sort( const tiles: Tile[] = [...reviewTiles, ...bundleTiles].sort(
(a, b) => b.date.valueOf() - a.date.valueOf() (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; tile.kind === 'review' ? tile.hero : tile.mosaic ? { kind: 'image' as const, src: tile.mosaic } : undefined;
return ( return (
<article> <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' && ( {visual?.kind === 'image' && (
<Image <Image
class="media" class="media"
@@ -155,8 +155,8 @@ const itemList = buildItemList(
/> />
)} )}
<div class="text"> <div class="text">
{tile.kind === 'list' && ( {tile.kind === 'bundle' && (
<span class="eyebrow">List · {tile.itemCount} picks</span> <span class="eyebrow">Bundle · {tile.itemCount} picks</span>
)} )}
<span class="name">{tile.name}</span> <span class="name">{tile.name}</span>
{tile.subtitle && <span class="subtitle">{tile.subtitle}</span>} {tile.subtitle && <span class="subtitle">{tile.subtitle}</span>}
@@ -260,7 +260,7 @@ const itemList = buildItemList(
color: var(--accent); color: var(--accent);
font-weight: 600; 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-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-weight: 700; font-weight: 700;
font-size: 1.15rem; font-size: 1.15rem;
+8 -8
View File
@@ -2,10 +2,10 @@ import rss from '@astrojs/rss';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
export async function GET(context) { export async function GET(context) {
const [reviews, posts, finds] = await Promise.all([ const [reviews, posts, bundles] = await Promise.all([
getCollection('reviews'), getCollection('reviews'),
getCollection('posts'), getCollection('posts'),
getCollection('finds'), getCollection('bundles'),
]); ]);
const items = [ const items = [
@@ -23,12 +23,12 @@ export async function GET(context) {
link: `/posts/${p.id}/`, link: `/posts/${p.id}/`,
categories: [p.data.category, ...p.data.tags], categories: [p.data.category, ...p.data.tags],
})), })),
...finds.map((f) => ({ ...bundles.map((b) => ({
title: f.data.title, title: b.data.title,
description: f.data.description ?? f.data.blurb ?? '', description: b.data.description ?? b.data.blurb ?? '',
pubDate: f.data.date, pubDate: b.data.date,
link: `/finds/${f.id}/`, link: `/bundles/${b.id}/`,
categories: [f.data.category, ...f.data.tags], categories: [b.data.category, ...b.data.tags],
})), })),
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf()); ].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
+4 -4
View File
@@ -4,7 +4,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<BaseLayout <BaseLayout
title="Search" title="Search"
description="Search reviews, finds, lists, and posts across Unique." description="Search reviews, finds, bundles, and posts across Unique."
subheader="Search" subheader="Search"
noindex noindex
> >
@@ -13,7 +13,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
id="q" id="q"
type="search" type="search"
name="q" name="q"
placeholder="Search reviews, finds, lists, posts…" placeholder="Search reviews, finds, bundles, posts…"
aria-label="Search" aria-label="Search"
autofocus autofocus
/> />
@@ -29,7 +29,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
const results = document.getElementById('results') as HTMLDivElement; const results = document.getElementById('results') as HTMLDivElement;
type Entry = { type Entry = {
type: 'review' | 'find' | 'list' | 'post'; type: 'review' | 'find' | 'bundle' | 'post';
url: string; url: string;
title: string; title: string;
subtitle: string; subtitle: string;
@@ -42,7 +42,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
const typeLabels: Record<Entry['type'], string> = { const typeLabels: Record<Entry['type'], string> = {
review: 'Review', review: 'Review',
find: 'Find', find: 'Find',
list: 'List', bundle: 'Bundle',
post: 'Post', post: 'Post',
}; };
+10 -10
View File
@@ -1,10 +1,10 @@
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
export async function GET() { export async function GET() {
const [reviews, finds, lists, posts] = await Promise.all([ const [reviews, finds, bundles, posts] = await Promise.all([
getCollection('reviews'), getCollection('reviews'),
getCollection('find'), getCollection('find'),
getCollection('finds'), getCollection('bundles'),
getCollection('posts'), getCollection('posts'),
]); ]);
@@ -28,14 +28,14 @@ export async function GET() {
blurb: f.data.description ?? '', blurb: f.data.description ?? '',
source: f.data.source, source: f.data.source,
})), })),
...lists.map((l) => ({ ...bundles.map((b) => ({
type: 'list', type: 'bundle',
url: `/finds/${l.id}/`, url: `/bundles/${b.id}/`,
title: l.data.title, title: b.data.title,
subtitle: l.data.blurb ?? '', subtitle: b.data.blurb ?? '',
date: l.data.date.toISOString(), date: b.data.date.toISOString(),
tags: l.data.tags ?? [], tags: b.data.tags ?? [],
blurb: l.data.description ?? '', blurb: b.data.description ?? '',
})), })),
...posts.map((p) => ({ ...posts.map((p) => ({
type: 'post', type: 'post',