import type { Metadata } from "next"; /** * Central SEO configuration — the single source of truth for the canonical * origin, brand naming, and shared metadata construction. Every page that needs * a canonical URL, an Open Graph card, or JSON-LD should build it from here so * the values can never drift between routes. */ export const SITE_NAME = "Podcast Distribution AI"; export const SITE_TAGLINE = "From topic idea to published podcast in minutes"; export const SITE_DESCRIPTION = "Podcast Distribution AI is an all-in-one AI platform that writes your script, records realistic multi-voice audio, and designs cover art — turning a topic into a finished episode in minutes."; /** Brand accent (--brand, light theme) as a hex literal for OG image rendering. */ export const BRAND_HEX = "#e65000"; /** * The canonical, absolute origin of the deployment (no trailing slash). * * Canonical tags, sitemap entries and OG URLs are only correct if this is the * real public origin, so a production build refuses to silently fall back to * localhost: an unset NEXT_PUBLIC_APP_URL would otherwise ship canonicals * pointing at http://localhost:3000 and de-index the whole site. */ function resolveSiteUrl(): string { const raw = process.env.NEXT_PUBLIC_APP_URL?.trim(); if (raw) { try { const url = new URL(raw); // Strip any trailing slash so `${SITE_URL}${path}` never doubles up. return url.origin; } catch { throw new Error( `[seo] NEXT_PUBLIC_APP_URL is not a valid absolute URL: ${JSON.stringify(raw)}` ); } } if (process.env.NODE_ENV === "production") { throw new Error( "[seo] NEXT_PUBLIC_APP_URL must be set in production — it is the canonical origin used for canonical tags, the sitemap, robots.txt and Open Graph URLs." ); } return "http://localhost:3000"; } export const SITE_URL = resolveSiteUrl(); /** Resolve a root-relative path to its absolute canonical URL. */ export function absoluteUrl(path = "/"): string { return path === "/" ? `${SITE_URL}/` : `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`; } /** * The site-wide social card (app/opengraph-image.tsx). * * It has to be restated on every page: Next merges `metadata` shallowly, so a * page that defines its own `openGraph` object replaces the root layout's * entirely — including the images the file convention contributed. Leaving this * out silently drops og:image from every page but the homepage. */ const DEFAULT_OG_IMAGE = { url: "/opengraph-image", width: 1200, height: 630, alt: `${SITE_NAME} — from a topic idea to a finished podcast in minutes`, }; interface PageMetaOptions { title: string; description: string; /** Root-relative path, e.g. "/pricing". Used for the canonical + OG URL. */ path: string; /** Keep the page out of the index (authed surfaces, share links, utilities). */ noIndex?: boolean; /** Override the social card image path (defaults to the site-wide OG card). */ image?: string; type?: "website" | "article"; } /** * Build a complete, canonical-tagged Metadata object for a page. Next.js merges * this over the root layout's metadata, so only the differing fields are set. */ export function pageMetadata({ title, description, path, noIndex = false, image, type = "website", }: PageMetaOptions): Metadata { const url = absoluteUrl(path); const images = image ? [{ url: image }] : [DEFAULT_OG_IMAGE]; return { title, description, alternates: { canonical: url }, openGraph: { title: `${title} · ${SITE_NAME}`, description, url, siteName: SITE_NAME, type, locale: "en_US", images, }, twitter: { card: "summary_large_image", title: `${title} · ${SITE_NAME}`, description, images, }, ...(noIndex ? { robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } } } : {}), }; } /** Metadata for surfaces that must never be indexed (app, admin, auth). */ export const NO_INDEX: Metadata = { robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false, noimageindex: true }, }, }; const MONTHS = [ "january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december", ]; /** * Convert a human "Month D, YYYY" stamp (as rendered on the legal pages) into a * calendar-only ISO-8601 date for schema.org `dateModified`. * * Parsed explicitly rather than via `new Date(...)` so the result can never be * shifted a day by the server's timezone. Returns undefined for anything it does * not recognise, so callers omit the property instead of emitting a bad date. */ export function toIsoDate(display: string): string | undefined { const match = /^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/.exec(display.trim()); if (!match) return undefined; const month = MONTHS.indexOf(match[1].toLowerCase()); if (month < 0) return undefined; const day = Number(match[2]); if (day < 1 || day > 31) return undefined; return `${match[3]}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; }