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).`} /> )}