diff --git a/.gitignore b/.gitignore index 797217f..1b8fbc5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,7 @@ next-env.d.ts .mcp.json # DB Backups -db-backups \ No newline at end of file +db-backups + +# Eval run artifacts +evals/results \ No newline at end of file diff --git a/src/app/[id]/page.tsx b/src/app/[id]/page.tsx index cd5772f..d634df5 100644 --- a/src/app/[id]/page.tsx +++ b/src/app/[id]/page.tsx @@ -1,11 +1,5 @@ import Link from "next/link"; -import { getBillByIdFromDB } from "@/server/get-bill-by-id-from-db"; -import { getBillFromCivicsProjectApi } from "@/services/billApi"; -import { - fromBuildCanadaDbBill, - fromCivicsProjectApiBill, - type UnifiedBill, -} from "@/utils/billConverters"; +import { getUnifiedBillById } from "@/server/get-unified-bill"; import type { Metadata, ResolvingMetadata } from "next"; import { headers } from "next/headers"; import { env } from "@/env"; @@ -23,10 +17,12 @@ import { authOptions } from "@/lib/auth"; import { BillTenets } from "@/components/BillDetail/BillTenets"; import { JudgementValue } from "@/components/Judgement/judgement.component"; import { buildAbsoluteUrl, buildRelativePath } from "@/utils/basePath"; +import { getCanonicalOrigin, stripMarkdown, truncate } from "@/utils/seo"; import { BUILD_CANADA_TWITTER_HANDLE, BUILD_CANADA_URL, } from "@/consts/general"; +import { JsonLd } from "@/components/JsonLd"; import { BillShare } from "@/components/BillDetail/BillShare"; import { shouldShowDetermination } from "@/utils/should-show-determination/should-show-determination.util"; @@ -51,18 +47,8 @@ export default async function BillDetail({ params }: Params) { env.NODE_ENV === "production" ? BUILD_CANADA_URL : origin || BUILD_CANADA_URL; - // Try database first, then fallback to API - const dbBill = await getBillByIdFromDB(id); - let unifiedBill: UnifiedBill | null = null; - - if (dbBill) { - unifiedBill = fromBuildCanadaDbBill(dbBill); - } else { - const apiBill = await getBillFromCivicsProjectApi(id); - if (apiBill) { - unifiedBill = await fromCivicsProjectApiBill(apiBill); - } - } + // Try database first, then fallback to API (memoized; shared with generateMetadata) + const unifiedBill = await getUnifiedBillById(id); if (!unifiedBill) { return ( @@ -83,8 +69,55 @@ export default async function BillDetail({ params }: Params) { ); const judgementValue: JudgementValue = unifiedBill.final_judgment; + const canonicalOrigin = getCanonicalOrigin(); + const canonicalUrl = buildAbsoluteUrl(canonicalOrigin, id); + const homeUrl = `${canonicalOrigin}${buildRelativePath()}`; + const billName = unifiedBill.short_title || unifiedBill.title; + const legislationJsonLd = { + "@context": "https://schema.org", + "@type": "Legislation", + name: billName, + legislationIdentifier: unifiedBill.billId, + legislationType: "Bill", + legislationJurisdiction: "Canada", + description: unifiedBill.summary + ? truncate(stripMarkdown(unifiedBill.summary), 300) + : `Bill ${id} analysis and judgement`, + url: canonicalUrl, + ...(unifiedBill.introducedOn + ? { + legislationDate: new Date(unifiedBill.introducedOn) + .toISOString() + .slice(0, 10), + } + : {}), + ...(unifiedBill.sponsorParty + ? { + sponsor: { + "@type": "Organization", + name: unifiedBill.sponsorParty, + }, + } + : {}), + }; + const breadcrumbJsonLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Bills", item: homeUrl }, + { + "@type": "ListItem", + position: 2, + name: unifiedBill.billId, + item: canonicalUrl, + }, + ], + }; + return (
+ +
← Back to bills @@ -152,15 +185,28 @@ export async function generateMetadata( const { id } = await params; const sp = await searchParams; const q = sp?.q; - const title = id; - const description = `Bill ${id} analysis and judgement`; - const h = headers(); - const host = (await h).get("x-forwarded-host") || (await h).get("host") || ""; - const proto = ((await h).get("x-forwarded-proto") || "https").split(",")[0]; - // Ensure we always have a base URL for absolute image URLs (required for Twitter Cards) - const baseUrl = - env.NEXT_PUBLIC_APP_URL || - (host ? `${proto}://${host}` : "http://localhost:3000"); + + // Memoized — shares the fetch with the page component for this request. + const bill = await getUnifiedBillById(id); + + // Don't index "bill not found" pages. + if (!bill) { + return { + title: id, + description: `Bill ${id} analysis and judgement`, + robots: { index: false, follow: false }, + }; + } + + const billName = bill.short_title || bill.title; + const title = billName ? truncate(`${id}: ${billName}`, 70) : id; + const description = bill.summary + ? truncate(stripMarkdown(bill.summary), 155) + : `Bill ${id} analysis and judgement`; + const keywords = + bill.genres && bill.genres.length > 0 ? bill.genres : undefined; + + const baseUrl = getCanonicalOrigin(); const pagePath = buildRelativePath(id); const pageUrl = `${baseUrl}${pagePath}`; const pageUrlWithQuery = q @@ -184,6 +230,7 @@ export async function generateMetadata( return { title, description, + keywords, alternates: { canonical: pageUrl }, openGraph: { title, @@ -200,15 +247,5 @@ export async function generateMetadata( site: BUILD_CANADA_TWITTER_HANDLE, images: [ogImageUrl], }, - other: { - "twitter:card": "summary_large_image", - "twitter:title": title, - "twitter:description": description, - "twitter:image": ogImageUrl, - "twitter:image:alt": `Analysis card for Bill ${title}`, - "twitter:creator": BUILD_CANADA_TWITTER_HANDLE, - "twitter:site": BUILD_CANADA_TWITTER_HANDLE, - "twitter:url": pageUrlWithQuery, - }, }; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e7e37c3..818eba7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,6 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Footer } from "@/components/Footer/footer.component"; import { SessionProvider } from "@/components/SessionProvider"; -import { env } from "@/env"; import { GoogleAnalytics } from "@next/third-parties/google"; import { BUILD_CANADA_TWITTER_HANDLE, @@ -13,6 +12,7 @@ import { import { Nav } from "@/components/Nav/nav.component"; import { SimpleAnalytics } from "@/components/SimpleAnalytics"; import { Toaster } from "@/components/ui/sonner"; +import { getCanonicalOrigin } from "@/utils/seo"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -30,9 +30,7 @@ export const metadata: Metadata = { template: `%s · ${PROJECT_NAME}`, }, description: "Understand Canadian Federal Bills", - metadataBase: env.NEXT_PUBLIC_APP_URL - ? new URL(env.NEXT_PUBLIC_APP_URL) - : undefined, + metadataBase: new URL(getCanonicalOrigin()), openGraph: { type: "website", siteName: PROJECT_NAME, @@ -56,11 +54,6 @@ export const metadata: Metadata = { description: "Understand Canadian Federal Bills", images: ["https://buildcanada.com/bills/builder-mp-seo-image.png"], }, - other: { - "twitter:card": "summary_large_image", - "twitter:site": BUILD_CANADA_TWITTER_HANDLE, - "twitter:creator": BUILD_CANADA_TWITTER_HANDLE, - }, }; export default async function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 317e52e..9cf31ff 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,25 +1,21 @@ import { BillSummary } from "./types"; import BillExplorer from "./BillExplorer"; -import { getAllBillsFromDB } from "@/server/get-all-bills-from-db"; -import { fromBuildCanadaDbBill } from "@/utils/billConverters"; +import { getMergedBills } from "@/server/get-merged-bills"; import { getParliament45Header } from "@/components/BillDetail/BillHeader"; +import { JsonLd } from "@/components/JsonLd"; import Markdown from "react-markdown"; import type { Metadata } from "next"; -import { headers } from "next/headers"; -import { env } from "@/env"; import { buildRelativePath } from "@/utils/basePath"; -import { BUILD_CANADA_TWITTER_HANDLE, PROJECT_NAME } from "@/consts/general"; +import { getCanonicalOrigin } from "@/utils/seo"; +import { + BUILD_CANADA_TWITTER_HANDLE, + BUILD_CANADA_URL, + PROJECT_NAME, +} from "@/consts/general"; import FAQModalTrigger from "./FAQModalTrigger"; -const CANADIAN_PARLIAMENT_NUMBER = 45; type HomeSearchParams = { cache?: string }; -function toIsoString(value?: Date | string): string | undefined { - if (!value) return undefined; - const parsed = value instanceof Date ? value : new Date(value); - return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); -} - // Force runtime generation (avoid build-time pre-render) and cache in-memory. export const dynamic = "auto"; // Next.js requires route segment configs to be literal values (not imported constants) @@ -29,14 +25,7 @@ export async function generateMetadata(): Promise { const title = "Home"; const description = "Understand Canadian federal bills with builder-first analysis."; - const h = headers(); - const headerList = await h; - const host = - headerList.get("x-forwarded-host") || headerList.get("host") || ""; - const proto = (headerList.get("x-forwarded-proto") || "https").split(",")[0]; - const baseUrl = - env.NEXT_PUBLIC_APP_URL || - (host ? `${proto}://${host}` : "http://localhost:3000"); + const baseUrl = getCanonicalOrigin(); const pagePath = buildRelativePath(); const pageUrl = `${baseUrl}${pagePath}`; const ogPath = buildRelativePath("opengraph-image"); @@ -64,128 +53,12 @@ export async function generateMetadata(): Promise { site: BUILD_CANADA_TWITTER_HANDLE, images: [ogImageUrl], }, - other: { - "twitter:card": "summary_large_image", - "twitter:title": PROJECT_NAME, - "twitter:description": description, - "twitter:image": ogImageUrl, - "twitter:image:alt": PROJECT_NAME, - "twitter:creator": BUILD_CANADA_TWITTER_HANDLE, - "twitter:site": BUILD_CANADA_TWITTER_HANDLE, - "twitter:url": pageUrl, - }, }; } const shouldUseLocalCache = process.env.NODE_ENV === "production"; let mergedBillsCache: { data: BillSummary[]; expiresAt: number } | null = null; -async function getApiBills(): Promise { - try { - const response = await fetch( - `${env.CIVICS_PROJECT_BASE_URL}/canada/bills/${CANADIAN_PARLIAMENT_NUMBER}`, - { - // Cache for 5 minutes in production, no cache in development - ...(process.env.NODE_ENV === "production" - ? { next: { revalidate: 300 } } - : { cache: "no-store" }), - headers: { - "Content-Type": "application/json", - Authorization: env.CIVICS_PROJECT_API_KEY - ? `Bearer ${env.CIVICS_PROJECT_API_KEY}` - : "", - }, - }, - ); - if (!response.ok) { - throw new Error("Failed to fetch bills from API"); - } - const { data } = await response.json(); - return Array.isArray(data) ? (data as BillSummary[]) : (data?.bills ?? []); - } catch (error) { - console.error("Error fetching API bills:", error); - return []; - } -} - -async function getMergedBills(): Promise { - const apiBills = await getApiBills(); - const uri = process.env.MONGO_URI || ""; - const hasValidMongoUri = - uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://"); - const dbBills = hasValidMongoUri ? await getAllBillsFromDB() : []; - - // Convert DB bills to UnifiedBill format first, then to BillSummary - const dbBillsAsUnified = dbBills.map(fromBuildCanadaDbBill); - - // Create a map of DB bills by billId for quick lookup - const dbBillsMap = new Map( - dbBillsAsUnified.map((bill) => [bill.billId, bill]), - ); - - // Merge API bills with DB data - const mergedBills: BillSummary[] = apiBills.map((apiBill) => { - const dbBill = dbBillsMap.get(apiBill.billID); - - if (dbBill) { - // Merge API bill with DB data (DB data takes precedence for analysis fields) - return { - ...dbBill, - ...apiBill, - shortTitle: dbBill.short_title || apiBill.shortTitle, - summary: dbBill.summary, - isSocialIssue: dbBill.isSocialIssue, - final_judgment: dbBill.final_judgment as BillSummary["final_judgment"], - rationale: dbBill.rationale, - needs_more_info: dbBill.needs_more_info, - missing_details: dbBill.missing_details, - genres: dbBill.genres, - parliamentNumber: dbBill.parliamentNumber, - sessionNumber: dbBill.sessionNumber, - }; - } - - // Return API bill as-is if no DB data - return apiBill; - }); - - // Add any DB-only bills that aren't in the API response - for (const [billId, dbBill] of dbBillsMap) { - if (!mergedBills.find((bill) => bill.billID === billId)) { - // Convert DB bill to BillSummary format - const billSummary: BillSummary = { - billID: dbBill.billId, - title: dbBill.title, - shortTitle: dbBill.short_title, - stages: dbBill.stages || [], - description: dbBill.summary || "", - status: (dbBill.status as BillSummary["status"]) || "Introduced", - sponsorParty: dbBill.sponsorParty || "Unknown", - sponsorName: "Unknown", - chamber: - (dbBill.chamber as "House of Commons" | "Senate") || - "House of Commons", - introducedOn: - toIsoString(dbBill.introducedOn) || new Date().toISOString(), - lastUpdatedOn: - toIsoString(dbBill.lastUpdatedOn) || new Date().toISOString(), - summary: dbBill.summary, - isSocialIssue: dbBill.isSocialIssue, - final_judgment: dbBill.final_judgment as BillSummary["final_judgment"], - rationale: dbBill.rationale, - needs_more_info: dbBill.needs_more_info, - missing_details: dbBill.missing_details, - genres: dbBill.genres, - parliamentNumber: dbBill.parliamentNumber, - sessionNumber: dbBill.sessionNumber, - }; - mergedBills.push(billSummary); - } - } - - return mergedBills; -} - async function getMergedBillsCached(): Promise { if (!shouldUseLocalCache) { // Avoid stale data while iterating locally; always hit the backing store. @@ -214,8 +87,23 @@ export default async function Home({ mergedBillsCache = null; // Allow manual cache busting with ?cache=clear } const bills = await getMergedBillsCached(); + const homeUrl = `${getCanonicalOrigin()}${buildRelativePath()}`; + const websiteJsonLd = { + "@context": "https://schema.org", + "@type": "WebSite", + name: PROJECT_NAME, + description: + "Understand Canadian federal bills with builder-first analysis.", + url: homeUrl, + publisher: { + "@type": "Organization", + name: "Build Canada", + url: BUILD_CANADA_URL, + }, + }; return (
+
diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..7a8c5d3 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,22 @@ +import type { MetadataRoute } from "next"; +import { buildRelativePath } from "@/utils/basePath"; +import { getCanonicalOrigin } from "@/utils/seo"; + +// Served at /bills/robots.txt. +export default function robots(): MetadataRoute.Robots { + const origin = getCanonicalOrigin(); + return { + rules: { + userAgent: "*", + allow: buildRelativePath(), + disallow: [ + buildRelativePath("sign-in"), + buildRelativePath("unauthorized"), + `${buildRelativePath("dev")}/`, + `${buildRelativePath("api")}/`, + `${buildRelativePath()}/*/edit`, + ], + }, + sitemap: `${origin}${buildRelativePath("sitemap.xml")}`, + }; +} diff --git a/src/app/robots.txt b/src/app/robots.txt deleted file mode 100644 index 34a7220..0000000 --- a/src/app/robots.txt +++ /dev/null @@ -1,7 +0,0 @@ -User-agent: * -Allow: / - -User-agent: Twitterbot -Allow: / - -Sitemap: https://buildcanada.com/sitemap.xml diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..1179df6 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,41 @@ +import type { MetadataRoute } from "next"; +import { getMergedBills } from "@/server/get-merged-bills"; +import { buildRelativePath } from "@/utils/basePath"; +import { getCanonicalOrigin } from "@/utils/seo"; + +// Served at /bills/sitemap.xml. Next.js does NOT prefix basePath into the URL +// strings, so we build fully-qualified canonical URLs via the helpers below. +export const revalidate = 3600; // 1 hour + +function toDate(value?: string | Date): Date | undefined { + if (!value) return undefined; + const parsed = value instanceof Date ? value : new Date(value); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} + +export default async function sitemap(): Promise { + const origin = getCanonicalOrigin(); + const homeEntry: MetadataRoute.Sitemap[number] = { + url: `${origin}${buildRelativePath()}`, + changeFrequency: "hourly", + priority: 1, + }; + + try { + const bills = await getMergedBills(); + const billEntries: MetadataRoute.Sitemap = bills + .filter((bill) => Boolean(bill.billID)) + .map((bill) => ({ + url: `${origin}${buildRelativePath(bill.billID)}`, + lastModified: toDate(bill.lastUpdatedOn) ?? toDate(bill.introducedOn), + changeFrequency: "daily", + priority: 0.8, + })); + + return [homeEntry, ...billEntries]; + } catch (error) { + console.error("Error generating sitemap:", error); + // Never 500 the sitemap — fall back to the home entry. + return [homeEntry]; + } +} diff --git a/src/components/JsonLd.tsx b/src/components/JsonLd.tsx new file mode 100644 index 0000000..d5ea1f5 --- /dev/null +++ b/src/components/JsonLd.tsx @@ -0,0 +1,15 @@ +/** + * Renders a schema.org JSON-LD block. Server component — emits a single + *