diff --git a/.env.example b/.env.example index f85b346b..e3a6fba3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org + # BFF API Server (server-side fetches) BFF_URL=http://localhost:8080 diff --git a/app/(app)/books/bbd/[chapter]/page.tsx b/app/(app)/books/bbd/[chapter]/page.tsx index 6aff09c3..b3bf9ca2 100644 --- a/app/(app)/books/bbd/[chapter]/page.tsx +++ b/app/(app)/books/bbd/[chapter]/page.tsx @@ -13,6 +13,8 @@ import { getProseChapter } from "@/lib/api"; import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og"; import { BOOK_NAMES } from "@/lib/constants"; import { PageHeader } from "@/components/layout/page-header"; +import { JsonLd } from "@/components/seo/json-ld"; +import { articleJsonLd } from "@/lib/jsonld"; import { Card, CardContent } from "@/components/ui/card"; import { buttonVariants } from "@/components/ui/button"; import { ChevronLeft, ChevronRight } from "lucide-react"; @@ -71,6 +73,7 @@ export default async function BbdChapterPage({ return (
+ + + + + + + + + + {/* Structured data for the currently-shown verse (SSR'd for the initial + verse, updates on client navigation). */} + {currentVerse ? ( + + ) : null} + {/* ---- Sidebar: Verse Index ---- */} {/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for the site footer so the sidebar doesn't slide behind it when the diff --git a/app/(app)/books/ds/[chapter]/page.tsx b/app/(app)/books/ds/[chapter]/page.tsx index 5904783c..23fa7d14 100644 --- a/app/(app)/books/ds/[chapter]/page.tsx +++ b/app/(app)/books/ds/[chapter]/page.tsx @@ -13,6 +13,8 @@ import { getProseChapter } from "@/lib/api"; import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og"; import { BOOK_NAMES } from "@/lib/constants"; import { PageHeader } from "@/components/layout/page-header"; +import { JsonLd } from "@/components/seo/json-ld"; +import { articleJsonLd } from "@/lib/jsonld"; import { Card, CardContent } from "@/components/ui/card"; import { buttonVariants } from "@/components/ui/button"; import { ChevronLeft, ChevronRight } from "lucide-react"; @@ -77,6 +79,7 @@ export default async function DsChapterPage({ return (
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) get a rich OG card * pointing at /api/og/profile/. The layout and the body * below are client components; only this file is server. - * 2. Mount the client tree (), which reads the - * ProfileContext provided by the parent layout for the post-grid - * hrefs and renders the feed. + * 2. Emit server-rendered ProfilePage/Person JSON-LD so crawlers + LLMs + * can resolve the profile (the layout is a client component and + * fetches the profile after hydration, too late for crawlers). + * 3. Mount the client tree (), which reads the + * ProfileContext provided by the parent layout. */ import type { Metadata } from "next"; +import { bff } from "@/lib/bff"; +import { JsonLd } from "@/components/seo/json-ld"; +import { profileJsonLd } from "@/lib/jsonld"; import { PostsGrid } from "./posts-grid"; import { metadataForHandle } from "./profile-og"; @@ -23,6 +28,32 @@ export async function generateMetadata({ params }: Props): Promise { return metadataForHandle(handle); } -export default function PublicProfilePage({ params }: Props) { - return ; +export default async function PublicProfilePage({ params }: Props) { + const { handle } = await params; + + let person: object | null = null; + try { + const profile = await bff<{ + handle: string; + name: string; + avatar_url?: string | null; + }>(`/u/${encodeURIComponent(handle)}`, { revalidate: 60 }); + if (profile) { + person = profileJsonLd({ + path: `/u/${profile.handle}`, + name: profile.name, + handle: profile.handle, + image: profile.avatar_url ?? null, + }); + } + } catch { + // Profile unavailable — skip JSON-LD rather than fail the page. + } + + return ( + <> + {person ? : null} + + + ); } diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts new file mode 100644 index 00000000..ea5c72c8 --- /dev/null +++ b/app/api/revalidate/route.ts @@ -0,0 +1,28 @@ +import { revalidateTag } from "next/cache"; +import type { NextRequest } from "next/server"; + +/** + * On-demand cache revalidation. The BFF pings this (fire-and-forget) when + * sitemap-relevant data changes — a new signup, or a content re-seed/reindex — + * so /sitemap.xml refreshes within seconds instead of waiting out the 24h ISR + * window. Secret-gated; lives under /api/ which robots.txt disallows. + * + * POST /api/revalidate?tag=sitemap&secret=… + */ +export const runtime = "nodejs"; + +export async function POST(req: NextRequest) { + const expected = process.env.REVALIDATE_SECRET; + const secret = req.nextUrl.searchParams.get("secret"); + if (!expected || secret !== expected) { + return Response.json( + { revalidated: false, error: "unauthorized" }, + { status: 401 }, + ); + } + const tag = req.nextUrl.searchParams.get("tag") || "sitemap"; + // Next 16 requires a cache-life profile as the 2nd arg; "max" fully purges + // the tagged entries so the next request re-fetches from the BFF. + revalidateTag(tag, "max"); + return Response.json({ revalidated: true, tag }); +} diff --git a/app/layout.tsx b/app/layout.tsx index dc7cfa4f..66f3723e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -43,6 +43,12 @@ const bricolage = Bricolage_Grotesque({ }); export const metadata: Metadata = { + // Resolves all relative OG/Twitter image URLs and canonical links to + // absolute URLs (required for correct social previews + SEO; Next warns + // without it). + metadataBase: new URL( + process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org", + ), title: "Prabhupāda Says", description: "Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda", diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 00000000..cc5a0dae --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,45 @@ +import type { MetadataRoute } from "next"; + +const SITE_URL = + process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org"; + +/** + * robots.txt (generated). + * + * Open the whole site to crawlers — search engines AND AI/LLM crawlers + * (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, etc. all honour the + * `*` rule, and we deliberately don't block them so the corpus is + * discoverable). Only private / non-content surfaces are disallowed: + * + * • /api/ — JSON endpoints, never a destination page + * • /settings — auth-only account settings + * • /notifications — auth-only + * • /verify-email — transactional + * • /chat — interactive tool, not indexable content + * • /u/me, /u/me/* — the signed-in user's own private library + * ($ anchors the bare path so public handles like + * /u/meera aren't caught by the prefix) + * + * Public content (books, verses, prose, letters, transcriptions, essays, + * disciples, magazines, journals, glossary, public profiles, collections) + * stays crawlable and is enumerated in the sitemap index. + */ +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: [ + "/api/", + "/settings", + "/notifications", + "/verify-email", + "/chat", + "/u/me$", + "/u/me/", + ], + }, + sitemap: `${SITE_URL}/sitemap.xml`, + host: SITE_URL, + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 00000000..21a03945 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,97 @@ +import type { MetadataRoute } from "next"; +import { bff } from "@/lib/bff"; + +/** + * Sitemap (index + sharded children) for the whole public corpus. + * + * How this stays cheap on the BFF: + * • One projection-only BFF call (`/sitemap/content`) returns every content + * leaf path in a single sweep — no per-document fan-out. + * • That fetch is cached for 24h (revalidate below) AND the rendered XML is + * ISR-cached, so crawlers hit Next's cache, not the BFF. The BFF sees + * ~one query/day regardless of crawl volume (tag "sitemap" allows + * on-demand revalidation after a re-seed). + * • generateSitemaps() shards into ≤40k-URL files (under the 50k/50MB + * limits) and Next serves a sitemap index at /sitemap.xml automatically. + */ + +export const revalidate = 86400; // 24h + +const SITE_URL = + process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org"; +const PER_SITEMAP = 40000; + +// Top-level hubs that aren't content leaves but should be indexed. +const STATIC_PATHS = [ + "/", + "/about", + "/books", + "/letters", + "/transcriptions", + "/disciples", + "/glossary", + "/essays", + "/magazines", + "/journals", + "/other-documents", +]; + +/** + * All indexable paths: static hubs + book/division landing pages (derived + * from the content leaves) + every content leaf from the BFF. Resilient — if + * the BFF call fails we still emit the static hubs so /sitemap.xml never 500s. + */ +async function fetchUrls(path: string): Promise { + try { + const res = await bff<{ urls: string[] }>(path, { + revalidate, + tags: ["sitemap"], + }); + return res?.urls ?? []; + } catch { + return []; + } +} + +async function allPaths(): Promise { + // Content (Mongo) and public profiles (Postgres) come from separate + // projection-only endpoints. New signups appear automatically on the next + // daily revalidation. + const [content, users] = await Promise.all([ + fetchUrls("/sitemap/content"), + fetchUrls("/sitemap/users"), + ]); + + // Derive non-redirecting hub pages from the content leaves: every book's + // landing page, and the SB canto / CC līlā division pages. + const hubs = new Set(); + for (const p of content) { + const book = p.match(/^\/books\/([^/]+)/); + if (book) hubs.add(`/books/${book[1]}`); + const division = p.match(/^\/books\/(sb|cc)\/([^/]+)\/\d+\/\d+$/); + if (division) hubs.add(`/books/${division[1]}/${division[2]}`); + } + + // Dedupe across static + hubs + content + profiles. + return [...new Set([...STATIC_PATHS, ...hubs, ...content, ...users])]; +} + +export async function generateSitemaps(): Promise<{ id: number }[]> { + const paths = await allPaths(); + const count = Math.max(1, Math.ceil(paths.length / PER_SITEMAP)); + return Array.from({ length: count }, (_, id) => ({ id })); +} + +export default async function sitemap({ + id, +}: { + id: number; +}): Promise { + const paths = await allPaths(); + const slice = paths.slice(id * PER_SITEMAP, (id + 1) * PER_SITEMAP); + return slice.map((path) => ({ + url: `${SITE_URL}${path}`, + changeFrequency: "weekly", + priority: path === "/" ? 1 : STATIC_PATHS.includes(path) ? 0.8 : 0.7, + })); +} diff --git a/components/seo/json-ld.tsx b/components/seo/json-ld.tsx new file mode 100644 index 00000000..a09c6079 --- /dev/null +++ b/components/seo/json-ld.tsx @@ -0,0 +1,14 @@ +/** + * Renders a schema.org JSON-LD