This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maple-brief-aggregator/src/app/article/[slug]/page.tsx
T
krisf 86a77b4907 Serve article images locally: /images proxy route with SSRF-guarded
upstream fetch, magic-byte validation, persistent /data/media cache,
immutable cache headers, CoverImage fallback component, og:image to
absolute proxy URL. Ported from technews 6f08da3.
2026-08-18 18:11:44 -04:00

172 lines
5.4 KiB
TypeScript

import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getArticleFull, getRelatedArticles } from '@/lib/queries';
import RelatedArticles from '@/components/RelatedArticles';
import { InArticleAd, SidebarAd } from '@/components/ads/placements';
import { categoryCover, ogImageUrl, resolveCoverImage } from '@/lib/cover';
import { CoverImage } from '@/components/CoverImage';
import { env } from '@/lib/env';
import { formatFull } from '@/lib/format';
import { safeParse } from '@/lib/llm/parse';
import { SECTIONS } from '@/data/feeds';
export const dynamic = 'force-dynamic';
interface ArticleData {
id: string;
slug: string;
title: string;
headline: string | null;
body: string | null;
takeaways: string | null;
tags: string | null;
siteName: string;
category: string;
author: string | null;
publishedAt: Date | null;
synthesizedAt: Date | null;
sourceUrl: string;
canonicalUrl: string;
image: string | null;
sources: string | null;
originalText: string | null;
llmProvider: string | null;
}
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const a = await getArticleFull(params.slug);
if (!a) return { title: 'Briefing not found' };
const title = a.headline ?? a.title;
const description = a.body?.split('\n\n')[0]?.slice(0, 160) ?? '';
const sectionLabel = SECTIONS.find((s) => s.slug === a.category)?.label ?? 'Briefing';
return {
title,
description,
alternates: { canonical: a.canonicalUrl },
openGraph: {
type: 'article',
title,
description,
url: a.canonicalUrl,
siteName: env.siteName,
publishedTime: a.publishedAt?.toISOString(),
modifiedTime: a.synthesizedAt?.toISOString(),
section: sectionLabel,
tags: a.tags ? safeParse<string[]>(a.tags).slice(0, 5) : undefined,
images: [{
url: ogImageUrl(a.image, a.category),
width: 1200,
height: 630,
alt: title,
}],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImageUrl(a.image, a.category)],
},
};
}
export default async function ArticlePage({
params,
}: {
params: { slug: string };
}) {
const a = await getArticleFull(params.slug);
if (!a || !a.body) notFound();
const paragraphs = a.body.split('\n\n').map((p) => p.trim()).filter(Boolean);
const takeaways = a.takeaways ? safeParse<string[]>(a.takeaways) : [];
const tags = a.tags ? safeParse<string[]>(a.tags) : [];
const relatedItems = await getRelatedArticles(a.slug, 6);
const title = a.headline ?? a.title;
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
<div className="grid gap-10 lg:grid-cols-3">
{/* Article column */}
<article className="lg:col-span-2">
<header>
<p className="mb-3 flex flex-wrap items-center gap-2 text-sm">
<span className="rounded bg-maple-600/10 px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-maple-700">
{SECTIONS.find((s) => s.slug === a.category)?.label ?? 'Briefing'}
</span>
{a.publishedAt && (
<span className="text-ink-400">· {formatFull(a.publishedAt.toISOString())}</span>
)}
</p>
<h1 className="font-display text-3xl font-bold leading-tight text-ink-950 sm:text-4xl">
{title}
</h1>
</header>
<CoverImage
src={resolveCoverImage(a.image, a.category)}
category={a.category}
alt={title}
loading="eager"
className="mt-6 aspect-[16/8] w-full rounded-2xl object-cover"
/>
{takeaways.length > 0 && (
<section className="mt-8 rounded-xl border-l-4 border-maple-600 bg-maple-50 p-5">
<h2 className="text-xs font-bold uppercase tracking-wider text-maple-800">
Key takeaways
</h2>
<ul className="mt-3 grid gap-2 sm:grid-cols-1">
{takeaways.map((t, i) => (
<li key={i} className="flex gap-2 text-[15px] leading-snug text-ink-800">
<span aria-hidden className="mt-0.5 font-bold text-maple-600"></span>
{t}
</li>
))}
</ul>
</section>
)}
<div className="article-prose mt-8">
{paragraphs.slice(0, 1).map((p, i) => (
<p key={i}>{p}</p>
))}
{/* In-article ad: between rewritten paragraphs 1 and 2 */}
<InArticleAd />
{paragraphs.slice(1).map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
{tags.length > 0 && (
<div className="mt-8 flex flex-wrap gap-2">
{tags.map((t) => (
<Link
key={t}
href={`/tag/${t}`}
className="rounded-full bg-ink-100 px-3 py-1 text-sm text-ink-700 hover:bg-maple-100 hover:text-maple-800"
>
#{t}
</Link>
))}
</div>
)}
<RelatedArticles items={relatedItems} />
</article>
{/* Sidebar */}
<aside aria-label="Sidebar" className="space-y-8">
<SidebarAd tall />
</aside>
</div>
</div>
);
}