diff --git a/.claude/skills/build-finds/SKILL.md b/.claude/skills/build-bundle/SKILL.md similarity index 52% rename from .claude/skills/build-finds/SKILL.md rename to .claude/skills/build-bundle/SKILL.md index 2530c56..712dd15 100644 --- a/.claude/skills/build-finds/SKILL.md +++ b/.claude/skills/build-bundle/SKILL.md @@ -1,39 +1,39 @@ --- -name: build-finds -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". +name: build-bundle +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 --- -# 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. ## Inputs - `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: 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 ### 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. 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. - 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. - 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. + 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 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 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. - **If the user provided a theme phrase only:** scan all finds, surface the 8–12 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 1–3 thematic clusters of 5–10 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 1–3 thematic clusters of 5–10 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 @@ -48,7 +48,7 @@ Seed (your selection): - : - ... -Expansion (unreferenced finds matching the theme): +Expansion (unbundled finds matching the theme): - : - ... @@ -69,9 +69,9 @@ Cluster B — "" 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/-.mdx` (theme-slug = kebab-case of the theme phrase, lowercase, ~30 chars max). +File: `src/content/bundles/-/index.mdx` (theme-slug = kebab-case of the theme phrase, lowercase, ~30 chars max). Frontmatter: @@ -81,7 +81,7 @@ title: "" date: YYYY-MM-DD tags: [daily-finds, , ] description: "" -blurb: "" +blurb: "" items: - - @@ -99,15 +99,15 @@ Example body: ### 4. Verify -- Confirm every slug in `items[]` exists at `src/content/find/.mdx`. If any is missing, abort and report — never reference a non-existent find. -- Run `npm run build` to confirm the new superpost compiles. +- Confirm every slug in `items[]` exists at `src/content/find//index.mdx`. If any is missing, abort and report — never reference a non-existent find. +- Run `npm run build` to confirm the new bundle compiles. ### 5. Update the log Append to `daily-finds.log.md`: ``` -## YYYY-MM-DD — superpost: +## YYYY-MM-DD — bundle: Slug: - Items referenced: @@ -118,7 +118,7 @@ Items referenced: ## 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. -- Never edit existing find files when building a superpost. The find body and metadata are stable; the superpost 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. +- 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 bundle — the grid card simply links to the find page, which itself links forward to the review. diff --git a/.claude/skills/build-review/SKILL.md b/.claude/skills/build-review/SKILL.md index dcaf5f8..db9c3bc 100644 --- a/.claude/skills/build-review/SKILL.md +++ b/.claude/skills/build-review/SKILL.md @@ -72,14 +72,34 @@ Edit `src/content/find/.mdx` frontmatter: add `promotedTo: `. (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: - `/reviews//` exists, shows the source badge and "← First surfaced…" backlink to the find page. - `/find//` 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`: diff --git a/.claude/skills/cluster-finds/SKILL.md b/.claude/skills/cluster-bundles/SKILL.md similarity index 52% rename from .claude/skills/cluster-finds/SKILL.md rename to .claude/skills/cluster-bundles/SKILL.md index 425be46..4440e22 100644 --- a/.claude/skills/cluster-finds/SKILL.md +++ b/.claude/skills/cluster-bundles/SKILL.md @@ -1,19 +1,19 @@ --- -name: cluster-finds -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. +name: cluster-bundles +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 --- -# 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/-.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/-/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. ## Inputs - `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 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. -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 @@ -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. - 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. ### 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. @@ -52,15 +52,15 @@ Cluster A — "" (N items) Cluster B — "" (N items) ... -Overlap with existing posts: appears in + ; ... +Overlap with existing bundles: appears in + ; ... Uncovered finds: ``` 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/-.mdx`: +Same file format as `build-bundle`. For each cluster, write `src/content/bundles/-/index.mdx`: ```yaml --- @@ -75,19 +75,19 @@ items: - ... --- -> +> ``` -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. - 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 -- Every slug in every `items[]` must exist at `src/content/find/.mdx`. Confirm before declaring done. -- Run `npm run build`. If any superpost fails to compile, fix and re-run. +- Every slug in every `items[]` must exist at `src/content/find//index.mdx`. Confirm before declaring done. +- Run `npm run build`. If any bundle fails to compile, fix and re-run. ### 7. Update the log @@ -96,11 +96,11 @@ Append to `daily-finds.log.md`: ``` ## YYYY-MM-DD — cluster run -Wrote superposts from a pool of finds. -Reuses across posts: (budget: ~20%). +Wrote bundles from a pool of finds. +Reuses across bundles: (budget: ~20%). Uncovered finds: . -Posts created: +Bundles created: - -: (N items) - <date>-<slug-B>: <title> (N items) - ... @@ -108,7 +108,7 @@ Posts created: ## 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. -- Date all posts 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. +- 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 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. diff --git a/.claude/skills/daily-finds/SKILL.md b/.claude/skills/daily-finds/SKILL.md index 4de7583..1932719 100644 --- a/.claude/skills/daily-finds/SKILL.md +++ b/.claude/skills/daily-finds/SKILL.md @@ -1,12 +1,12 @@ --- 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 --- # 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. @@ -108,7 +108,7 @@ Short summary in chat: ``` Captured <N> finds across <M> productive sources today. 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." diff --git a/CHANGELOG.md b/CHANGELOG.md index 755b534..f64bb84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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 - 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 diff --git a/README.md b/README.md index 3abc955..99898ec 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ Built with [Astro](https://astro.build) + MDX. ## 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) -- **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`) +- **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-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 -- **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 -- **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 +- **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 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 - **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) - 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 @@ -113,13 +113,13 @@ description: "Optional <meta> tag override." 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 ``` src/ - content.config.ts # collection schemas (reviews, posts, find, finds) + content.config.ts # collection schemas (reviews, posts, find, bundles) content/ reviews/<slug>/ index.mdx # article + frontmatter @@ -128,7 +128,7 @@ src/ find/<slug>/ index.mdx 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 lib/ entries.ts # getAllEntries() — unified feed across collections diff --git a/package.json b/package.json index 15fc400..46d4cb1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "private": true, "scripts": { - "build:mosaics": "node scripts/build-list-mosaics.mjs", + "build:mosaics": "node scripts/build-bundle-mosaics.mjs", "predev": "npm run build:mosaics", "prestart": "npm run build:mosaics", "prebuild": "npm run build:mosaics", diff --git a/scripts/build-list-mosaics.mjs b/scripts/build-bundle-mosaics.mjs similarity index 87% rename from scripts/build-list-mosaics.mjs rename to scripts/build-bundle-mosaics.mjs index ac81299..de26690 100644 --- a/scripts/build-list-mosaics.mjs +++ b/scripts/build-bundle-mosaics.mjs @@ -1,5 +1,5 @@ -// Build-time generator: for each list in src/content/finds, compose a -// mosaic JPG from the item heroes available in that list. Skips work +// Build-time generator: for each bundle in src/content/bundles, compose a +// mosaic JPG from the item heroes available in that bundle. Skips work // when output is newer than every input. Layout depends on hero count: // 0 -> no mosaic (file is removed if it exists) // 1 -> 1200x1200 single image @@ -15,9 +15,9 @@ import sharp from 'sharp'; const __dirname = dirname(fileURLToPath(import.meta.url)); 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 OUT_DIR = join(ROOT, 'src/generated/list-mosaics'); +const OUT_DIR = join(ROOT, 'src/generated/bundle-mosaics'); const TILE = 598; const GAP = 4; @@ -122,7 +122,7 @@ async function tryUnlink(path) { async function main() { 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()) .map((d) => d.name); @@ -131,13 +131,13 @@ async function main() { let removed = 0; let absent = 0; - for (const listSlug of listDirs) { - const mdxPath = join(FINDS_DIR, listSlug, 'index.mdx'); - const mdPath = join(FINDS_DIR, listSlug, 'index.md'); + for (const bundleSlug of bundleDirs) { + const mdxPath = join(BUNDLES_DIR, bundleSlug, 'index.mdx'); + const mdPath = join(BUNDLES_DIR, bundleSlug, 'index.md'); const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null; if (!sourcePath) continue; - const outPath = join(OUT_DIR, `${listSlug}.jpg`); + const outPath = join(OUT_DIR, `${bundleSlug}.jpg`); const fm = await readFrontmatter(sourcePath); const items = parseItems(fm); if (items.length === 0) { @@ -165,10 +165,10 @@ async function main() { await buildMosaic(heroPaths, outPath); built++; - console.log(` built ${listSlug}.jpg (${heroPaths.length}-up)`); + console.log(` built ${bundleSlug}.jpg (${heroPaths.length}-up)`); } 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)` ); } diff --git a/src/components/Backlinks.astro b/src/components/Backlinks.astro index 001cb7a..568a5e1 100644 --- a/src/components/Backlinks.astro +++ b/src/components/Backlinks.astro @@ -1,8 +1,8 @@ --- -import type { FindsPost } from '../lib/backlinks'; +import type { Bundle } from '../lib/backlinks'; interface Props { - posts: FindsPost[]; + posts: Bundle[]; label?: string; } const { posts, label = 'Featured in' } = Astro.props; @@ -15,7 +15,7 @@ const { posts, label = 'Featured in' } = Astro.props; {posts.map((p, i) => ( <> {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> diff --git a/src/components/EditConfirm.astro b/src/components/EditConfirm.astro index fcf558a..f205d69 100644 --- a/src/components/EditConfirm.astro +++ b/src/components/EditConfirm.astro @@ -135,7 +135,7 @@ let command = btn.dataset.command || ''; let description = btn.dataset.confirm || ''; - if (btn.dataset.editAction === 'finds-collection') { + if (btn.dataset.editAction === 'bundle-from-selection') { const checked = Array.from( document.querySelectorAll<HTMLInputElement>('[data-find-checkbox]:checked') ); @@ -144,10 +144,10 @@ return; } const slugs = checked.map((cb) => cb.dataset.slug).filter(Boolean).join(','); - command = `/build-finds ${slugs}`; - description = `Build a finds collection from ${checked.length} selected find${ + command = `/build-bundle ${slugs}`; + description = `Bundle ${checked.length} selected find${ checked.length === 1 ? '' : 's' - }.`; + } under one theme.`; } if (btn.dataset.editAction === 'tag-prompt') { diff --git a/src/components/EntryGrid.astro b/src/components/EntryGrid.astro new file mode 100644 index 0000000..8b86ee5 --- /dev/null +++ b/src/components/EntryGrid.astro @@ -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> diff --git a/src/content.config.ts b/src/content.config.ts index 5c32f60..8b9766b 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -60,16 +60,16 @@ const find = defineCollection({ }), }); -const finds = defineCollection({ +const bundles = defineCollection({ loader: glob({ pattern: '**/index.mdx', - base: './src/content/finds', + base: './src/content/bundles', generateId: stripIndex, }), schema: z.object({ title: z.string(), date: z.coerce.date(), - category: z.string().default('finds'), + category: z.string().default('bundles'), tags: z.array(z.string()).default([]), description: 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 }; diff --git a/src/content/finds/2026-05-03-backyard-small-upgrades/index.mdx b/src/content/bundles/2026-05-03-backyard-small-upgrades/index.mdx similarity index 100% rename from src/content/finds/2026-05-03-backyard-small-upgrades/index.mdx rename to src/content/bundles/2026-05-03-backyard-small-upgrades/index.mdx diff --git a/src/content/finds/2026-05-03-mac-apps-quietly-worth-it/index.mdx b/src/content/bundles/2026-05-03-mac-apps-quietly-worth-it/index.mdx similarity index 100% rename from src/content/finds/2026-05-03-mac-apps-quietly-worth-it/index.mdx rename to src/content/bundles/2026-05-03-mac-apps-quietly-worth-it/index.mdx diff --git a/src/content/finds/2026-05-03-manufactured-delight/index.mdx b/src/content/bundles/2026-05-03-manufactured-delight/index.mdx similarity index 100% rename from src/content/finds/2026-05-03-manufactured-delight/index.mdx rename to src/content/bundles/2026-05-03-manufactured-delight/index.mdx diff --git a/src/content/finds/2026-05-03-morning-kitchen-ritual/index.mdx b/src/content/bundles/2026-05-03-morning-kitchen-ritual/index.mdx similarity index 100% rename from src/content/finds/2026-05-03-morning-kitchen-ritual/index.mdx rename to src/content/bundles/2026-05-03-morning-kitchen-ritual/index.mdx diff --git a/src/content/finds/2026-05-03-pocket-carry/index.mdx b/src/content/bundles/2026-05-03-pocket-carry/index.mdx similarity index 100% rename from src/content/finds/2026-05-03-pocket-carry/index.mdx rename to src/content/bundles/2026-05-03-pocket-carry/index.mdx diff --git a/src/content/finds/2026-05-03-single-virtue-tools/index.mdx b/src/content/bundles/2026-05-03-single-virtue-tools/index.mdx similarity index 100% rename from src/content/finds/2026-05-03-single-virtue-tools/index.mdx rename to src/content/bundles/2026-05-03-single-virtue-tools/index.mdx diff --git a/src/content/finds/2026-05-03-six-clean-groans/index.mdx b/src/content/bundles/2026-05-03-six-clean-groans/index.mdx similarity index 100% rename from src/content/finds/2026-05-03-six-clean-groans/index.mdx rename to src/content/bundles/2026-05-03-six-clean-groans/index.mdx diff --git a/src/content/finds/2026-05-03-travel-as-attention/index.mdx b/src/content/bundles/2026-05-03-travel-as-attention/index.mdx similarity index 100% rename from src/content/finds/2026-05-03-travel-as-attention/index.mdx rename to src/content/bundles/2026-05-03-travel-as-attention/index.mdx diff --git a/src/content/finds/2026-05-04-a-small-atlas/index.mdx b/src/content/bundles/2026-05-04-a-small-atlas/index.mdx similarity index 100% rename from src/content/finds/2026-05-04-a-small-atlas/index.mdx rename to src/content/bundles/2026-05-04-a-small-atlas/index.mdx diff --git a/src/content/finds/2026-05-04-geography-is-the-program/index.mdx b/src/content/bundles/2026-05-04-geography-is-the-program/index.mdx similarity index 100% rename from src/content/finds/2026-05-04-geography-is-the-program/index.mdx rename to src/content/bundles/2026-05-04-geography-is-the-program/index.mdx diff --git a/src/content/finds/2026-05-04-off-until-needed/index.mdx b/src/content/bundles/2026-05-04-off-until-needed/index.mdx similarity index 100% rename from src/content/finds/2026-05-04-off-until-needed/index.mdx rename to src/content/bundles/2026-05-04-off-until-needed/index.mdx diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 6c8886d..2294571 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -137,7 +137,7 @@ const year = new Date().getFullYear(); <ul> <li><a href="/grid/">Reviews</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> </ul> </section> @@ -417,6 +417,120 @@ const year = new Date().getFullYear(); font-style: italic; } 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> {import.meta.env.DEV && <EditConfirm />} </body> diff --git a/src/lib/backlinks.ts b/src/lib/backlinks.ts index 6f7af0b..829ea4b 100644 --- a/src/lib/backlinks.ts +++ b/src/lib/backlinks.ts @@ -1,9 +1,9 @@ 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[]> { - const all = await getCollection('finds'); +export async function bundlesReferencing(slug: string): Promise<Bundle[]> { + const all = await getCollection('bundles'); return all .filter((p) => p.data.items.includes(slug)) .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); diff --git a/src/lib/find-list-mosaic.ts b/src/lib/bundle-mosaic.ts similarity index 64% rename from src/lib/find-list-mosaic.ts rename to src/lib/bundle-mosaic.ts index 5a92af7..8c5df97 100644 --- a/src/lib/find-list-mosaic.ts +++ b/src/lib/bundle-mosaic.ts @@ -1,16 +1,16 @@ import type { ImageMetadata } from 'astro'; const modules = import.meta.glob<{ default: ImageMetadata }>( - '../generated/list-mosaics/*.jpg', + '../generated/bundle-mosaics/*.jpg', { eager: true } ); const mosaics = new Map<string, ImageMetadata>(); 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); } -export function findListMosaic(slug: string): ImageMetadata | undefined { +export function bundleMosaic(slug: string): ImageMetadata | undefined { return mosaics.get(slug); } diff --git a/src/lib/entries.ts b/src/lib/entries.ts index 42a224f..c919f0e 100644 --- a/src/lib/entries.ts +++ b/src/lib/entries.ts @@ -1,7 +1,7 @@ import { getCollection } from 'astro:content'; export type Entry = { - type: 'review' | 'post' | 'finds'; + type: 'review' | 'post' | 'bundle'; id: string; href: string; date: Date; @@ -14,7 +14,7 @@ export type Entry = { export async function getAllEntries(): Promise<Entry[]> { const reviews = await getCollection('reviews'); const posts = await getCollection('posts'); - const findsPosts = await getCollection('finds'); + const bundles = await getCollection('bundles'); const r: Entry[] = reviews.map((e) => ({ type: 'review', @@ -37,10 +37,10 @@ export async function getAllEntries(): Promise<Entry[]> { primary: e.data.title, })); - const f: Entry[] = findsPosts.map((e) => ({ - type: 'finds', + const b: Entry[] = bundles.map((e) => ({ + type: 'bundle', id: e.id, - href: `/finds/${e.id}/`, + href: `/bundles/${e.id}/`, date: e.data.date, category: e.data.category, tags: e.data.tags, @@ -48,7 +48,7 @@ export async function getAllEntries(): Promise<Entry[]> { 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 = { @@ -57,7 +57,7 @@ export type SiteStats = { reviews: number; finds: number; posts: number; - lists: number; + bundles: number; }; 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 finds = findItems.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 = [ ...entries.map((e) => e.date), ...findItems.map((f) => f.data.date), ].sort((a, b) => b.valueOf() - a.valueOf()); return { - total: reviews + finds + posts + lists, + total: reviews + finds + posts + bundles, lastUpdated: allDates[0] ?? null, reviews, finds, posts, - lists, + bundles, }; } diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 10cf2b2..7dd5758 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -1,13 +1,13 @@ import { heroes } from './hero'; 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 FALLBACK_PATHS: Record<OgFallback, string> = { reviews: '/og/reviews.png', - finds: '/og/finds.png', + bundles: '/og/bundles.png', posts: '/og/posts.png', site: '/og/default.png', }; diff --git a/src/pages/finds/[...slug].astro b/src/pages/bundles/[...slug].astro similarity index 66% rename from src/pages/finds/[...slug].astro rename to src/pages/bundles/[...slug].astro index b30036f..44f8132 100644 --- a/src/pages/finds/[...slug].astro +++ b/src/pages/bundles/[...slug].astro @@ -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> diff --git a/src/pages/finds/index.astro b/src/pages/bundles/index.astro similarity index 76% rename from src/pages/finds/index.astro rename to src/pages/bundles/index.astro index 5dc21f8..c3e6dc3 100644 --- a/src/pages/finds/index.astro +++ b/src/pages/bundles/index.astro @@ -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; diff --git a/src/pages/find/[...slug].astro b/src/pages/find/[...slug].astro index 5e70f5f..386f49f 100644 --- a/src/pages/find/[...slug].astro +++ b/src/pages/find/[...slug].astro @@ -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; diff --git a/src/pages/find/index.astro b/src/pages/find/index.astro index 2cb4832..5faf5ee 100644 --- a/src/pages/find/index.astro +++ b/src/pages/find/index.astro @@ -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> )} diff --git a/src/pages/index.astro b/src/pages/index.astro index 974db5b..6c76b56 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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; diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js index ea7105b..6d9fb2e 100644 --- a/src/pages/rss.xml.js +++ b/src/pages/rss.xml.js @@ -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()); diff --git a/src/pages/search.astro b/src/pages/search.astro index 555c401..791193e 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -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', }; diff --git a/src/pages/search.json.js b/src/pages/search.json.js index 290583e..19a7b3f 100644 --- a/src/pages/search.json.js +++ b/src/pages/search.json.js @@ -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',