Code, configs, README, CHANGELOG, sources/log files, .claude. Subsequent chunks land the renamed media files.
231 lines
6.1 KiB
Plaintext
231 lines
6.1 KiB
Plaintext
---
|
|
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 { findHero } from '../../lib/find-hero';
|
|
import EditAction from '../../components/EditAction.astro';
|
|
import {
|
|
resolveExternalLink,
|
|
AFFILIATE_REL,
|
|
AFFILIATE_DISCLOSURE,
|
|
} from '../../lib/affiliate';
|
|
import { buildOgImage, buildBreadcrumbs, absoluteUrl } from '../../lib/seo';
|
|
|
|
export async function getStaticPaths() {
|
|
const items = await getCollection('find');
|
|
return items.map((item) => ({
|
|
params: { slug: item.id },
|
|
props: { item },
|
|
}));
|
|
}
|
|
|
|
const { item } = Astro.props;
|
|
const { Content } = await render(item);
|
|
const featuringPosts = await findsReferencing(item.id);
|
|
const hero = findHero(item.id);
|
|
|
|
const linkLabel =
|
|
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
|
|
|
|
const external = resolveExternalLink(item.data.link);
|
|
|
|
const description =
|
|
item.data.description ?? item.data.subtitle ?? `A find: ${item.data.name}.`;
|
|
|
|
const canonicalOverride = item.data.promotedTo
|
|
? absoluteUrl(`/reviews/${item.data.promotedTo}/`)
|
|
: undefined;
|
|
|
|
const ogImage = buildOgImage(
|
|
item.data.promotedTo,
|
|
'finds',
|
|
`${item.data.name} preview image`
|
|
);
|
|
|
|
const productSchema: Record<string, unknown> = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Product',
|
|
'@id': absoluteUrl(`/find/${item.id}/#product`),
|
|
name: item.data.name,
|
|
description,
|
|
url: absoluteUrl(`/find/${item.id}/`),
|
|
image: ogImage.url,
|
|
offers: {
|
|
'@type': 'Offer',
|
|
url: item.data.link,
|
|
availability: 'https://schema.org/InStock',
|
|
},
|
|
};
|
|
|
|
const breadcrumbs = buildBreadcrumbs([
|
|
{ name: 'Home', url: '/' },
|
|
{ name: 'Sources', url: '/sources/' },
|
|
{ name: item.data.name, url: `/find/${item.id}/` },
|
|
]);
|
|
---
|
|
|
|
<BaseLayout
|
|
title={item.data.name}
|
|
description={description}
|
|
subheader={item.data.name}
|
|
subheaderSubtitle={item.data.subtitle}
|
|
subheaderVariant="headline"
|
|
lastUpdated={item.data.date}
|
|
ogType="article"
|
|
ogImage={ogImage}
|
|
canonicalOverride={canonicalOverride}
|
|
publishedTime={item.data.date}
|
|
articleTags={item.data.tags}
|
|
jsonLd={[productSchema, breadcrumbs]}
|
|
>
|
|
<article>
|
|
<header class="find-header">
|
|
<p class="source-line"><SourceBadge source={item.data.source} /></p>
|
|
</header>
|
|
|
|
{hero && (
|
|
<Image
|
|
class="hero"
|
|
src={hero}
|
|
widths={[640, 960, 1280]}
|
|
sizes="(max-width: 720px) 92vw, 42rem"
|
|
loading="eager"
|
|
decoding="async"
|
|
alt={item.data.name}
|
|
/>
|
|
)}
|
|
|
|
<div class="body">
|
|
<Content />
|
|
</div>
|
|
|
|
<p class="external">
|
|
<a
|
|
href={external.href}
|
|
target="_blank"
|
|
rel={external.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
|
|
>
|
|
{linkLabel} ↗
|
|
</a>
|
|
{external.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
|
</p>
|
|
|
|
{external.isAffiliate && (
|
|
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</p>
|
|
)}
|
|
|
|
{
|
|
item.data.promotedTo && (
|
|
<p class="promoted">
|
|
→ Read the full review:
|
|
<a href={`/reviews/${item.data.promotedTo}/`}>{item.data.promotedTo}</a>
|
|
</p>
|
|
)
|
|
}
|
|
|
|
{import.meta.env.DEV && !item.data.promotedTo && (
|
|
<div class="edit-toolbar">
|
|
<EditAction
|
|
label="Graduate to review"
|
|
command={`/build-review ${item.id}`}
|
|
confirm={`This will draft src/content/reviews/${item.id}.mdx, expand the find body into a full review, propose a hero asset, link the find via promotedTo, and append a graduation note to daily-finds.log.md.`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
)
|
|
}
|
|
|
|
<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%;
|
|
height: auto;
|
|
max-height: 28rem;
|
|
object-fit: cover;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--rule);
|
|
background: var(--card-fill);
|
|
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;
|
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
border-radius: 6px;
|
|
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;
|
|
padding-top: 1rem;
|
|
border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--rule));
|
|
}
|
|
</style>
|
|
</BaseLayout>
|