From 80b3920715dbd9b09f5936c00150218db7c0e7b3 Mon Sep 17 00:00:00 2001
From: rzen
Date: Fri, 3 Jul 2026 15:16:25 -0400
Subject: [PATCH] site: hero auto-discovery, pick.json hero selection,
amazonLink + shortlist fields
- src/lib/hero.ts: review heroes are now auto-discovered via import.meta.glob
over reviews/*/hero.; only legacy non-hero-named assets remain as
explicit overrides. New reviews need zero registration.
- Pick of the day moves from a hardcoded HERO_SLUG in index.astro to
src/data/pick.json (content edit, not code edit); dev set-pick API and
the review-page button now write pick.json. Fallback: latest review.
- Schemas: finds gain shortlist (bool) + amazonLink; reviews gain amazonLink.
- Review + find pages render an 'Also on Amazon' affiliate link when
amazonLink is set, with shared disclosure handling.
- Both hero globs now include .avif (two June finds ship avif heroes).
---
astro.config.mjs | 8 +---
src/content.config.ts | 3 ++
src/data/pick.json | 3 ++
src/lib/find-hero.ts | 2 +-
src/lib/hero.ts | 76 ++++++++++++++-----------------
src/pages/find/[...slug].astro | 18 +++++++-
src/pages/index.astro | 9 +++-
src/pages/reviews/[...slug].astro | 20 +++++++-
8 files changed, 86 insertions(+), 53 deletions(-)
create mode 100644 src/data/pick.json
diff --git a/astro.config.mjs b/astro.config.mjs
index a1de583..995d878 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -12,7 +12,7 @@ const EXCLUDED_PATHS = new Set([
const FIND_ROOT = path.resolve('src/content/find');
const REVIEW_ROOT = path.resolve('src/content/reviews');
-const HOME_PAGE = path.resolve('src/pages/index.astro');
+const PICK_FILE = path.resolve('src/data/pick.json');
const VALID_SLUG = /^[a-z0-9][a-z0-9-]*$/;
const VALID_TAG = /^[a-z0-9][a-z0-9-]{0,40}$/i;
@@ -118,11 +118,7 @@ const setPickApi = {
if (!reviewPath.startsWith(REVIEW_ROOT + path.sep)) return send(res, 400, { error: 'path escape' });
try { await stat(reviewPath); } catch { return send(res, 404, { error: 'review not found' }); }
- const text = await readFile(HOME_PAGE, 'utf8');
- const re = /^(const HERO_SLUG = ')([^']+)(';)/m;
- if (!re.test(text)) return send(res, 422, { error: 'HERO_SLUG line not found in src/pages/index.astro' });
- const out = text.replace(re, (_, a, _b, c) => `${a}${slug}${c}`);
- await writeFile(HOME_PAGE, out, 'utf8');
+ await writeFile(PICK_FILE, JSON.stringify({ hero: slug }, null, 2) + '\n', 'utf8');
return send(res, 200, { slug });
} catch (err) {
diff --git a/src/content.config.ts b/src/content.config.ts
index 269cfcd..e246d4b 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -21,6 +21,7 @@ const reviews = defineCollection({
tags: z.array(z.string()).default([]),
link: z.string().url().optional(),
linkText: z.string().optional(),
+ amazonLink: z.string().url().optional(),
description: z.string().optional(),
source: z.string().optional(),
fromFind: z.string().optional(),
@@ -54,11 +55,13 @@ const find = defineCollection({
date: z.coerce.date(),
link: z.string().url(),
linkText: z.string().optional(),
+ amazonLink: z.string().url().optional(),
source: z.string(),
topics: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
description: z.string().optional(),
promotedTo: z.string().optional(),
+ shortlist: z.boolean().default(false),
editorialTags: z.array(z.string()).default([]),
}),
});
diff --git a/src/data/pick.json b/src/data/pick.json
new file mode 100644
index 0000000..120f152
--- /dev/null
+++ b/src/data/pick.json
@@ -0,0 +1,3 @@
+{
+ "hero": "continue-y-n-game"
+}
diff --git a/src/lib/find-hero.ts b/src/lib/find-hero.ts
index 87e1abb..4fca2a8 100644
--- a/src/lib/find-hero.ts
+++ b/src/lib/find-hero.ts
@@ -1,7 +1,7 @@
import type { ImageMetadata } from 'astro';
const modules = import.meta.glob<{ default: ImageMetadata }>(
- '../content/find/**/hero.{png,jpg,jpeg,gif,webp}',
+ '../content/find/**/hero.{png,jpg,jpeg,gif,webp,avif}',
{ eager: true }
);
diff --git a/src/lib/hero.ts b/src/lib/hero.ts
index eb19b6c..56eaaf5 100644
--- a/src/lib/hero.ts
+++ b/src/lib/hero.ts
@@ -1,34 +1,17 @@
import type { ImageMetadata } from 'astro';
-import alcove from '../content/reviews/alcove/hero.png';
-import altTab from '../content/reviews/alt-tab/hero.jpg';
-import apex from '../content/reviews/apex-markdown-processor/hero.png';
+// Legacy hero assets that predate the hero. naming convention.
+// New reviews should name their hero file hero. — it is auto-discovered
+// by the globs below and needs no registration here.
import bartender from '../content/reviews/bartender-6-dynamic-peninsula/peninsula.png';
-import blip from '../content/reviews/blip/hero.jpg';
-import continueYn from '../content/reviews/continue-y-n-game/hero.png';
-import cursorCamp from '../content/reviews/cursor-camp-neal-fun/hero.png';
-import editorio from '../content/reviews/editorio/hero.webp';
-import esproP7 from '../content/reviews/espro-p7-french-press/hero.jpg';
-import finalist from '../content/reviews/finalist/hero.png';
import folderPeek from '../content/reviews/folder-peek/screenshot.png';
import gnome from '../content/reviews/gnome-gif-menubar/screenshot.png';
-import gumShell from '../content/reviews/gum-shell/hero.png';
-import hyperkey from '../content/reviews/hyperkey/hero.png';
import ice from '../content/reviews/ice/ice-bar.png';
import macMouseFix from '../content/reviews/mac-mouse-fix/studio-display.png';
-import monitorcontrol from '../content/reviews/monitorcontrol/hero.png';
-import muxy from '../content/reviews/muxy-terminal/hero.png';
-import remctl from '../content/reviews/remctl-reminders-cli/hero.png';
-import smartmoverShortcut from '../content/reviews/smartmover-shortcut/hero.jpg';
-import superkey from '../content/reviews/superkey/hero.jpg';
-import tablepro from '../content/reviews/tablepro/hero.png';
-import timelined from '../content/reviews/timelined/hero.png';
import tot from '../content/reviews/tot/seven-dots.png';
-import valchooseBarfBags from '../content/reviews/valchoose-barf-bags/hero.jpg';
import zenteek from '../content/reviews/zenteek-music-player/library.png';
import antinote from '../content/reviews/antinote/swipe.mp4';
-import cotypist from '../content/reviews/cotypist/hero.mp4';
import dockdoor from '../content/reviews/dockdoor/alttab.mp4';
import fluxMarkdown from '../content/reviews/flux-markdown/demo.mp4';
import homerow from '../content/reviews/homerow/overlay.mp4';
@@ -41,35 +24,41 @@ export type Hero =
| { kind: 'image'; src: ImageMetadata }
| { kind: 'video'; src: string };
-export const heroes: Record = {
- alcove: { kind: 'image', src: alcove },
- 'alt-tab': { kind: 'image', src: altTab },
- 'apex-markdown-processor': { kind: 'image', src: apex },
+const imageModules = import.meta.glob<{ default: ImageMetadata }>(
+ '../content/reviews/*/hero.{png,jpg,jpeg,gif,webp,avif}',
+ { eager: true }
+);
+
+const videoModules = import.meta.glob('../content/reviews/*/hero.mp4', {
+ eager: true,
+ query: '?url',
+ import: 'default',
+});
+
+function slugOf(path: string): string | undefined {
+ return path.match(/\/reviews\/([^/]+)\//)?.[1];
+}
+
+const discovered: Record = {};
+for (const [path, mod] of Object.entries(imageModules)) {
+ const slug = slugOf(path);
+ if (slug) discovered[slug] = { kind: 'image', src: mod.default };
+}
+// A hero.mp4 wins over a sibling hero image.
+for (const [path, src] of Object.entries(videoModules)) {
+ const slug = slugOf(path);
+ if (slug) discovered[slug] = { kind: 'video', src };
+}
+
+const overrides: Record = {
'bartender-6-dynamic-peninsula': { kind: 'image', src: bartender },
- blip: { kind: 'image', src: blip },
- 'continue-y-n-game': { kind: 'image', src: continueYn },
- 'cursor-camp-neal-fun': { kind: 'image', src: cursorCamp },
- editorio: { kind: 'image', src: editorio },
- 'espro-p7-french-press': { kind: 'image', src: esproP7 },
- finalist: { kind: 'image', src: finalist },
'folder-peek': { kind: 'image', src: folderPeek },
'gnome-gif-menubar': { kind: 'image', src: gnome },
- 'gum-shell': { kind: 'image', src: gumShell },
- hyperkey: { kind: 'image', src: hyperkey },
ice: { kind: 'image', src: ice },
'mac-mouse-fix': { kind: 'image', src: macMouseFix },
- monitorcontrol: { kind: 'image', src: monitorcontrol },
- 'muxy-terminal': { kind: 'image', src: muxy },
- 'remctl-reminders-cli': { kind: 'image', src: remctl },
- 'smartmover-shortcut': { kind: 'image', src: smartmoverShortcut },
- superkey: { kind: 'image', src: superkey },
- tablepro: { kind: 'image', src: tablepro },
- timelined: { kind: 'image', src: timelined },
tot: { kind: 'image', src: tot },
- 'valchoose-barf-bags': { kind: 'image', src: valchooseBarfBags },
'zenteek-music-player': { kind: 'image', src: zenteek },
antinote: { kind: 'video', src: antinote },
- cotypist: { kind: 'video', src: cotypist },
dockdoor: { kind: 'video', src: dockdoor },
'flux-markdown': { kind: 'video', src: fluxMarkdown },
homerow: { kind: 'video', src: homerow },
@@ -78,3 +67,8 @@ export const heroes: Record = {
shottr: { kind: 'video', src: shottr },
typewhisper: { kind: 'video', src: typewhisper },
};
+
+export const heroes: Record = {
+ ...discovered,
+ ...overrides,
+};
diff --git a/src/pages/find/[...slug].astro b/src/pages/find/[...slug].astro
index 386f49f..8f338eb 100644
--- a/src/pages/find/[...slug].astro
+++ b/src/pages/find/[...slug].astro
@@ -31,6 +31,9 @@ const linkLabel =
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
const external = resolveExternalLink(item.data.link);
+const amazon = item.data.amazonLink
+ ? resolveExternalLink(item.data.amazonLink)
+ : undefined;
const description =
item.data.description ?? item.data.subtitle ?? `A find: ${item.data.name}.`;
@@ -109,7 +112,20 @@ const breadcrumbs = buildBreadcrumbs([
{external.isAffiliate && Affiliate}
- {external.isAffiliate && (
+ {amazon && (
+
+
+ Also on Amazon ↗
+
+ {amazon.isAffiliate && Affiliate}
+
+ )}
+
+ {(external.isAffiliate || amazon?.isAffiliate) && (
{AFFILIATE_DISCLOSURE}
)}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 254be49..18651ef 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -7,7 +7,10 @@ import { heroes } from '../lib/hero';
import { bundleMosaic } from '../lib/bundle-mosaic';
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
-const HERO_SLUG = 'continue-y-n-game';
+import pick from '../data/pick.json';
+
+// Pick of the day. Set in src/data/pick.json; null falls back to the latest review.
+const HERO_SLUG: string | null = pick.hero;
const INITIAL_GRID = 24;
const BATCH_SIZE = 7;
@@ -69,7 +72,9 @@ const tiles: Tile[] = [...reviewTiles, ...bundleTiles].sort(
const heroIndex = tiles.findIndex(
(t) => t.kind === 'review' && t.href === `/reviews/${HERO_SLUG}/`
);
-const hero = heroIndex >= 0 ? tiles[heroIndex] : reviewTiles[0];
+// Fall back to the newest review (tiles are sorted by date desc).
+const hero =
+ heroIndex >= 0 ? tiles[heroIndex] : tiles.find((t) => t.kind === 'review');
const rest = tiles.filter((t) => t !== hero);
const lastUpdated = tiles[0]?.date;
diff --git a/src/pages/reviews/[...slug].astro b/src/pages/reviews/[...slug].astro
index 9950e1a..f0f0d77 100644
--- a/src/pages/reviews/[...slug].astro
+++ b/src/pages/reviews/[...slug].astro
@@ -39,6 +39,9 @@ const linkLabel =
(review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, ''));
const external = review.data.link ? resolveExternalLink(review.data.link) : undefined;
+const amazon = review.data.amazonLink
+ ? resolveExternalLink(review.data.amazonLink)
+ : undefined;
const fromFindItem = review.data.fromFind
? (await getCollection('find')).find((f) => f.id === review.data.fromFind)
@@ -111,7 +114,20 @@ const categoryLabel =
)}
- {external?.isAffiliate && (
+ {amazon && (
+
+
+ Also on Amazon ↗
+
+ {amazon.isAffiliate && Affiliate}
+
+ )}
+
+ {(external?.isAffiliate || amazon?.isAffiliate) && (
{AFFILIATE_DISCLOSURE}
)}
@@ -121,7 +137,7 @@ const categoryLabel =
label="Make today's pick"
action="set-pick"
slug={review.id}
- confirm={`This will set ${review.data.name} as the homepage hero (rewrites HERO_SLUG in src/pages/index.astro).`}
+ confirm={`This will set ${review.data.name} as the homepage hero (writes src/data/pick.json).`}
/>
)}