generateMetadata still derived the page <title>/OG title from the generic
auto frontmatter topic ("Dow Uap D44 …"). Search engines, link cards, and
AI crawlers show that string, so it must be the human magazine headline the
narrator wrote as the body H1. Mirror the page component's H1 extraction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
/**
|
|
* /c/[slug] — Case file reader.
|
|
*
|
|
* Renders a single narrated case file from /data/ufo/case/reports/<slug>.md.
|
|
* The reader sees a magazine-style article — title, dateline, body. No
|
|
* detective attribution, no skeptic framing.
|
|
*/
|
|
import { notFound } from "next/navigation";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { Metadata } from "next";
|
|
import { MarkdownBody } from "@/components/markdown-body";
|
|
import { AuthBar } from "@/components/auth-bar";
|
|
import { BureauNav } from "@/components/bureau-nav";
|
|
import { getLocale } from "@/components/locale-toggle";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const CASE_ROOT = process.env.CASE_ROOT || "/data/ufo/case";
|
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://disclosure.top";
|
|
|
|
interface Frontmatter {
|
|
topic?: string;
|
|
topic_pt_br?: string;
|
|
created_at?: string;
|
|
job_id?: string;
|
|
}
|
|
|
|
function parseFrontmatter(md: string): { fm: Frontmatter; body: string } {
|
|
const m = md.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/);
|
|
if (!m) return { fm: {}, body: md };
|
|
const fm: Frontmatter = {};
|
|
for (const line of m[1].split("\n")) {
|
|
const kv = line.match(/^([a-z_]+):\s*(.+)$/);
|
|
if (!kv) continue;
|
|
let v = kv[2].trim();
|
|
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
|
(fm as Record<string, string>)[kv[1]] = v;
|
|
}
|
|
return { fm, body: m[2] };
|
|
}
|
|
|
|
async function loadCase(slug: string): Promise<{ fm: Frontmatter; body: string } | null> {
|
|
try {
|
|
const md = await readFile(path.join(CASE_ROOT, "reports", `${slug}.md`), "utf-8");
|
|
return parseFrontmatter(md);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function hasIllustration(slug: string): Promise<boolean> {
|
|
const fs = await import("node:fs/promises");
|
|
const UFO_ROOT = process.env.UFO_ROOT || "/data/ufo";
|
|
try {
|
|
await fs.stat(path.join(UFO_ROOT, "processing", "case-art", `${slug}.png`));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the first prose paragraph from the body for the meta description
|
|
* + OG. We pick the locale-preferred sub-section's opener.
|
|
*/
|
|
function pickLead(body: string, locale: "pt-br" | "en"): string {
|
|
const marker = locale === "pt-br" ? "(PT-BR)" : "(EN)";
|
|
const idx = body.indexOf(marker);
|
|
const slice = idx >= 0 ? body.slice(idx) : body;
|
|
const m = slice.match(/\n\n([^\n#>|`-][^\n]+(?:\n[^\n#>|`-][^\n]+)*)/);
|
|
return (m?.[1] ?? "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
export async function generateMetadata(
|
|
{ params }: { params: Promise<{ slug: string }> },
|
|
): Promise<Metadata> {
|
|
const { slug } = await params;
|
|
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
|
const c = await loadCase(slug);
|
|
if (!c) return { title: "Case file not found" };
|
|
|
|
// Prefer the narrator's body H1 (the magazine headline) over the generic
|
|
// auto-derived frontmatter topic ("Dow Uap D44 …"). This is what search
|
|
// engines and link cards show, so it must be the human title. Two H1s when
|
|
// present: EN then PT-BR; fall back across them, then to frontmatter.
|
|
const h1s = [...c.body.matchAll(/^#\s+(.+)$/gm)].map((m) => m[1].trim());
|
|
const title = locale === "pt-br"
|
|
? (h1s[1] ?? h1s[0] ?? c.fm.topic_pt_br ?? c.fm.topic ?? slug)
|
|
: (h1s[0] ?? c.fm.topic ?? slug);
|
|
const desc = pickLead(c.body, locale).slice(0, 200);
|
|
const canonical = `${SITE_URL}/c/${slug}`;
|
|
// OG image — use the case's editorial illustration when present. WhatsApp,
|
|
// Twitter, Slack, Telegram, ChatGPT search all pull this as the link card.
|
|
const ogImage = (await hasIllustration(slug))
|
|
? `${SITE_URL}/api/static/processing/case-art/${slug}.png`
|
|
: `${SITE_URL}/api/static/processing/case-art/green-fireballs-narrative.png`;
|
|
return {
|
|
title,
|
|
description: desc,
|
|
alternates: { canonical, languages: { "pt-BR": canonical, "en-US": canonical } },
|
|
openGraph: {
|
|
type: "article",
|
|
title,
|
|
description: desc,
|
|
url: canonical,
|
|
siteName: "The Disclosure Bureau",
|
|
locale: locale === "pt-br" ? "pt_BR" : "en_US",
|
|
publishedTime: c.fm.created_at,
|
|
images: [{ url: ogImage, width: 2000, height: 1125, alt: title }],
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title,
|
|
description: desc,
|
|
images: [ogImage],
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function CaseReportPage({
|
|
params,
|
|
}: { params: Promise<{ slug: string }> }) {
|
|
const { slug } = await params;
|
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) notFound();
|
|
|
|
const locale = (await getLocale()) === "en" ? "en" : "pt-br";
|
|
const c = await loadCase(slug);
|
|
if (!c) notFound();
|
|
const { fm } = c;
|
|
|
|
// The narrator writes two H1s in the body: "# <Title EN>" then
|
|
// "# <Título PT-BR>". Prefer those over the generic frontmatter topic
|
|
// (which is often an auto-derived "Doc 18 100754 …"). Pick by locale and
|
|
// strip BOTH leading H1s from the body so the headline isn't duplicated.
|
|
const h1s = [...c.body.matchAll(/^#\s+(.+)$/gm)].map((m) => m[1].trim());
|
|
const bodyTitle = locale === "pt-br" ? (h1s[1] ?? h1s[0]) : h1s[0];
|
|
// Strip H1 lines ("# Title") — the "## section" headers survive since the
|
|
// regex requires whitespace after a single #. Avoids duplicating the title.
|
|
const body = c.body.replace(/^#\s+.+$\n?/gm, "").replace(/^\s+/, "");
|
|
const title = bodyTitle ?? (locale === "pt-br" ? (fm.topic_pt_br ?? fm.topic ?? slug) : (fm.topic ?? slug));
|
|
const dateLabel = fm.created_at ? new Date(fm.created_at).toLocaleDateString(locale === "pt-br" ? "pt-BR" : "en-US", { day: "numeric", month: "long", year: "numeric" }) : null;
|
|
const canonical = `${SITE_URL}/c/${slug}`;
|
|
const lead = pickLead(body, locale).slice(0, 280);
|
|
const heroArt = (await hasIllustration(slug)) ? `/api/static/processing/case-art/${slug}.png` : null;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
|
|
<BureauNav locale={locale} crumbs={[
|
|
{ label: locale === "en" ? "case files" : "casos", href: "/bureau" },
|
|
{ label: title.length > 32 ? title.slice(0, 32) + "…" : title },
|
|
]} />
|
|
<AuthBar />
|
|
|
|
{/* Full-bleed editorial hero — only when a painterly illustration
|
|
exists for this case. Other cases get the plain title header. */}
|
|
{heroArt && (
|
|
<div className="relative w-full overflow-hidden border-b border-[rgba(224,192,128,0.15)]">
|
|
<div className="relative aspect-[16/9] md:aspect-[21/9] max-h-[640px]">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={heroArt}
|
|
alt={title}
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0e1a] via-[#0a0e1a]/30 to-transparent" />
|
|
<div className="absolute inset-0 bg-gradient-to-r from-[#0a0e1a]/70 via-transparent to-transparent" />
|
|
<div className="absolute bottom-0 left-0 right-0 px-4 md:px-8 pb-8 md:pb-12">
|
|
<div className="mx-auto max-w-3xl">
|
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-3">
|
|
{locale === "en" ? "Declassified case file" : "Arquivo desclassificado"}
|
|
{dateLabel && <> · {dateLabel}</>}
|
|
</div>
|
|
<h1 className="font-display text-4xl md:text-6xl font-semibold leading-[1.05] tracking-tight text-white drop-shadow-lg">
|
|
{title}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
<div className="absolute top-3 right-3 text-[9px] font-mono uppercase tracking-wider text-[#9aa6b8]/80 bg-[#0a0e1a]/70 px-2 py-1 rounded">
|
|
{locale === "en" ? "Editorial illustration" : "Ilustração editorial"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<article className="mx-auto max-w-3xl px-4 py-10 md:py-14">
|
|
{!heroArt && (
|
|
<header className="mb-10 md:mb-14">
|
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#e0c080] mb-4">
|
|
{locale === "en" ? "Declassified case file" : "Arquivo desclassificado"}
|
|
{dateLabel && <> · {dateLabel}</>}
|
|
</div>
|
|
<h1 className="font-display text-4xl md:text-6xl font-semibold leading-[1.05] tracking-tight text-[#e7ecf3] mb-6">
|
|
{title}
|
|
</h1>
|
|
</header>
|
|
)}
|
|
{lead && (
|
|
<p className={`text-lg md:text-xl text-[#cbd2dd] leading-relaxed font-light max-w-2xl ${heroArt ? "mb-10" : "mb-10 md:mb-14"}`}>
|
|
{lead}
|
|
</p>
|
|
)}
|
|
|
|
<div className="prose prose-invert prose-lg max-w-none
|
|
prose-headings:font-display prose-headings:font-semibold
|
|
prose-h2:text-2xl prose-h2:md:text-3xl prose-h2:mt-12 prose-h2:mb-4
|
|
prose-h2:border-b prose-h2:border-[rgba(224,192,128,0.15)] prose-h2:pb-3
|
|
prose-p:text-[16px] prose-p:leading-relaxed prose-p:text-[#cbd2dd]
|
|
prose-blockquote:border-l-[#e0c080] prose-blockquote:bg-[rgba(224,192,128,0.05)]
|
|
prose-blockquote:not-italic prose-blockquote:font-display prose-blockquote:text-[#e7ecf3]
|
|
prose-blockquote:py-2 prose-blockquote:px-4 prose-blockquote:rounded-r
|
|
prose-a:text-[#7fdbff] prose-a:no-underline hover:prose-a:underline
|
|
prose-strong:text-[#e7ecf3]">
|
|
<MarkdownBody>{body}</MarkdownBody>
|
|
</div>
|
|
</article>
|
|
|
|
{/* JSON-LD Article — helps Google + AI crawlers parse the case as
|
|
a citation-bearing piece of journalism */}
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify({
|
|
"@context": "https://schema.org",
|
|
"@type": "Article",
|
|
headline: title,
|
|
description: lead,
|
|
url: canonical,
|
|
datePublished: fm.created_at,
|
|
inLanguage: locale === "pt-br" ? "pt-BR" : "en-US",
|
|
isAccessibleForFree: true,
|
|
publisher: {
|
|
"@type": "Organization",
|
|
name: "The Disclosure Bureau",
|
|
url: SITE_URL,
|
|
},
|
|
about: { "@type": "Thing", name: "UAP/UFO declassified record" },
|
|
}) }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|