From 70b2fe687f3335daffc39840d256c900716f9354 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Date: Sun, 24 May 2026 16:09:50 -0300 Subject: [PATCH] W5.4 (Phase 3B): sitemap + robots + Article schema + magazine reading view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GEO/SEO surface area: app/robots.ts (new — Next.js dynamic robots) Explicitly ALLOWS major AI crawlers: GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-Web, anthropic-ai, PerplexityBot, Perplexity-User, Google-Extended, Applebot-Extended, CCBot, DuckAssistBot, YouBot, Bytespider, Amazonbot. The site exists to be cited by LLMs answering UAP/UFO questions — we want them in. /api/admin/, /admin/, /auth/ disallowed for everyone. app/sitemap.ts (new — Next.js dynamic sitemap) Lists 9 top-level routes + every /d/ + every /c/ from the filesystem + up to 500 entity URLs per class (event, person, uap_object, location, organization), sorted with summary-enriched entities first. ~3000 URLs total at current corpus size. lastModified honours summary_generated_at so crawlers re-index when entities are re-enriched. app/c/[slug]/page.tsx (rewritten — magazine reading view) - generateMetadata: per-case title, description (auto-extracted from the locale-preferred lead paragraph), canonical URL, hreflang alternate, OpenGraph article type with publishedTime, Twitter card. - JSON-LD Article schema embedded at end of page: schema.org Article + Organization publisher + inLanguage + isAccessibleForFree. This is what makes the case appear as a citable source in Google AI Overviews / Perplexity / ChatGPT search. - Reading view rewritten: display-serif headline (Fraunces), italic blockquotes with gold accent, prose-typography styling, no more detective stats line, no more "written by case-writer@detective" attribution. Locale-aware: PT-BR pulls topic_pt_br + lead in PT, English mirror. tailwind.config.ts + @tailwindcss/typography plugin + font-display family wired to var(--font-display) (Fraunces) package.json + "@tailwindcss/typography" devDependency Phase 3A note: bulk entity enrichment hit Claude OAuth weekly quota mid-run. 6 events + 3 uap_objects landed bilingual summaries before the quota exhausted. UI gracefully splits enriched vs bare entities so /sightings shows the magazine-grade cards (Kenneth Arnold 1947, Roswell, Maury Island, Joseph Perry 1960 lunar photo, Civil Defense Director 1966, etc.) on top of a compact table of the rest. Re-run when quota refreshes. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/app/c/[slug]/page.tsx | 176 ++++++++++++++++++++++++++------------ web/app/robots.ts | 43 ++++++++++ web/app/sitemap.ts | 122 ++++++++++++++++++++++++++ web/package-lock.json | 28 ++++++ web/package.json | 1 + web/tailwind.config.ts | 8 +- 6 files changed, 319 insertions(+), 59 deletions(-) create mode 100644 web/app/robots.ts create mode 100644 web/app/sitemap.ts diff --git a/web/app/c/[slug]/page.tsx b/web/app/c/[slug]/page.tsx index b9e53bb..b20159a 100644 --- a/web/app/c/[slug]/page.tsx +++ b/web/app/c/[slug]/page.tsx @@ -1,33 +1,30 @@ /** - * /c/[slug] — Case report viewer. + * /c/[slug] — Case file reader. * - * Reads /data/ufo/case/reports/.md, parses frontmatter for metadata, - * renders the markdown body via MarkdownBody. The case-writer detective - * writes these files; this page is the reader. + * Renders a single narrated case file from /data/ufo/case/reports/.md. + * The reader sees a magazine-style article — title, dateline, body. No + * detective attribution, no skeptic framing. */ import { notFound } from "next/navigation"; -import Link from "next/link"; 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; - created_by?: string; + topic_pt_br?: string; created_at?: string; job_id?: string; - n_evidence?: number; - n_hypotheses?: number; - n_contradictions?: number; - n_witnesses?: number; - n_outliers?: number; } function parseFrontmatter(md: string): { fm: Frontmatter; body: string } { @@ -37,74 +34,141 @@ function parseFrontmatter(md: string): { fm: Frontmatter; body: string } { for (const line of m[1].split("\n")) { const kv = line.match(/^([a-z_]+):\s*(.+)$/); if (!kv) continue; - const k = kv[1] as keyof Frontmatter; - let v: string | number = kv[2].trim(); + let v = kv[2].trim(); if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); - if (k === "n_evidence" || k === "n_hypotheses" || k === "n_contradictions" - || k === "n_witnesses" || k === "n_outliers") { - const n = Number(v); - if (Number.isFinite(n)) (fm[k] as number) = n; - } else { - (fm[k] as string) = v as string; - } + (fm as Record)[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; + } +} + +/** + * 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 { + 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" }; + + const title = locale === "pt-br" ? (c.fm.topic_pt_br ?? c.fm.topic ?? slug) : (c.fm.topic ?? slug); + const desc = pickLead(c.body, locale).slice(0, 200); + const canonical = `${SITE_URL}/c/${slug}`; + 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, + }, + twitter: { + card: "summary_large_image", + title, + description: desc, + }, + }; +} + export default async function CaseReportPage({ params, }: { params: Promise<{ slug: string }> }) { const { slug } = await params; if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) notFound(); - let md: string; - try { - md = await readFile(path.join(CASE_ROOT, "reports", `${slug}.md`), "utf-8"); - } catch { - notFound(); - } - const { fm, body } = parseFrontmatter(md); + const locale = (await getLocale()) === "en" ? "en" : "pt-br"; + const c = await loadCase(slug); + if (!c) notFound(); + const { fm, body } = c; - const stats: Array<{ label: string; value: number | undefined; color: string }> = [ - { label: "evidence", value: fm.n_evidence, color: "text-[#06d6a0]" }, - { label: "hypotheses", value: fm.n_hypotheses, color: "text-[#7fdbff]" }, - { label: "contradictions", value: fm.n_contradictions, color: "text-[#ff8a4d]" }, - { label: "witnesses", value: fm.n_witnesses, color: "text-[#9b5de5]" }, - { label: "outliers", value: fm.n_outliers, color: "text-[#ffd23f]" }, - ]; + const title = 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); return (
32 ? title.slice(0, 32) + "…" : title }, ]} /> -
-
-
- Case report{fm.created_by && <> · written by {fm.created_by}} - {fm.created_at && <> · {fm.created_at}} +
+
+
+ {locale === "en" ? "Declassified case file" : "Arquivo desclassificado"} + {dateLabel && <> · {dateLabel}}
- {fm.topic && ( -

{fm.topic}

+

+ {title} +

+ {lead && ( +

+ {lead} +

)} -
- {stats.filter((s) => typeof s.value === "number").map((s) => ( - - {s.value} - {s.label} - - ))} -
-
+ -
+
{body} -
-
+
+ + + {/* JSON-LD Article — helps Google + AI crawlers parse the case as + a citation-bearing piece of journalism */} +