diff --git a/app/components/FeaturedReports.tsx b/app/components/FeaturedReports.tsx new file mode 100644 index 0000000..aa0e34a --- /dev/null +++ b/app/components/FeaturedReports.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; +import { getRecentReports } from "@/lib/kv-cache"; +import { getReportMeta } from "@/lib/report-meta"; +import { DEMO_REPORT } from "@/lib/demo-report"; + +// Server-rendered list of public report pages, so crawlers reach `/analyze/*` +// from the homepage (the client `RecentReports` in the hero is localStorage-only +// and invisible to bots). The demo report is always attempted first so there is +// at least one real, indexable report link even when the recent-reports set is +// empty (e.g. Redis unconfigured). Enriched with WCL meta for descriptive anchor +// text; the homepage is ISR-cached (see `revalidate` in page.tsx) so this fans +// out to WCL at most once per revalidation window, not per request. + +const MAX_SHOWN = 5; +// Over-fetch candidates so transient meta failures don't shrink the list below +// MAX_SHOWN when there are enough reports to fill it. +const MAX_ATTEMPTS = MAX_SHOWN + 2; + +// WCL report titles are user-supplied and often junk ("??", blank) — fall back +// to the zone so anchor text never shows noise. Mirrors the analyze page. +function headline(title: string, zone: string): string { + const t = title?.trim(); + return t && !/^[?\s.]+$/.test(t) ? t : zone; +} + +export default async function FeaturedReports() { + const recent = await getRecentReports(MAX_ATTEMPTS * 2); + const candidates = [ + DEMO_REPORT.code, + ...recent.map((r) => r.code).filter((c) => c !== DEMO_REPORT.code), + ].slice(0, MAX_ATTEMPTS); + + // getReportMeta never throws (it returns a discriminated status), so Promise.all + // is safe; private/errored/missing reports resolve to null and drop out. + const resolved = await Promise.all( + candidates.map(async (code) => { + const res = await getReportMeta(code); + return res.status === "ok" ? { code, meta: res.meta } : null; + }), + ); + const shown = resolved + .filter((r): r is NonNullable => r !== null) + .slice(0, MAX_SHOWN); + + if (shown.length === 0) return null; + + return ( +
+

+ Recently Analyzed Reports +

+
+ {shown.map(({ code, meta }) => { + const h = headline(meta.title, meta.zone); + return ( + +
+

+ {h} — WoW Classic Raid Analysis +

+

+ {meta.zone} · {meta.players.length} raiders +

+
+ + View → + + + ); + })} +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 2b54320..d9c6769 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,22 @@ +import Link from "next/link"; import { LandingHero } from "./components/LandingHero"; +import FeaturedReports from "./components/FeaturedReports"; + +// ISR: regenerate hourly so the server-rendered report links below stay fresh +// without a redeploy, and so the WCL meta fan-out in FeaturedReports runs at most +// once per hour rather than per request. +export const revalidate = 3600; + +// Direct homepage → guide-article links. The Navbar only links the /guides hub; +// linking each article from the site's highest-authority page passes authority +// straight to the individual guides so the thinner ones get indexed. +const GUIDES = [ + { href: "/guides/how-to-analyze-wow-classic-logs", title: "How to Analyze WoW Classic Logs" }, + { href: "/guides/improve-dps-wow-classic", title: "How to Improve Your DPS in WoW Classic" }, + { href: "/guides/raid-preparation-checklist", title: "WoW Classic Raid Preparation Checklist" }, + { href: "/guides/wow-classic-loot-council-tools", title: "Best Loot Council Tools for WoW Classic" }, + { href: "/guides/warcraft-logs-vs-parseforge", title: "Warcraft Logs vs ParseForge" }, +]; const jsonLd = { "@context": "https://schema.org", @@ -74,6 +92,29 @@ export default function Home() { Lich King, Cataclysm Classic, and Anniversary realms.

+ + + +
+

Guides

+
    + {GUIDES.map((g) => ( +
  • + + {g.title} + +
  • + ))} +
  • + + All guides → + +
  • +
+
); diff --git a/app/sitemap.ts b/app/sitemap.ts index 7b1e6b6..525b52d 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,5 +1,6 @@ import type { MetadataRoute } from "next"; import { getRecentReports } from "@/lib/kv-cache"; +import { DEMO_REPORT } from "@/lib/demo-report"; const BASE = "https://parseforge.gg"; @@ -15,6 +16,11 @@ const STATIC_ROUTES: MetadataRoute.Sitemap = [ { url: `${BASE}/guides/raid-preparation-checklist`, changeFrequency: "monthly", priority: 0.7 }, { url: `${BASE}/guides/wow-classic-loot-council-tools`, changeFrequency: "monthly", priority: 0.7 }, { url: `${BASE}/guides/warcraft-logs-vs-parseforge`, changeFrequency: "monthly", priority: 0.7 }, + // The landing-page "See a live example" report. Hardcoded so at least one + // real, indexable report page is always crawlable even when the recent-reports + // pipeline below is empty (e.g. Redis unconfigured, or no public report + // rendered since deploy). Canonical form (no query params) to match the page. + { url: `${BASE}/analyze/${DEMO_REPORT.code}`, changeFrequency: "weekly", priority: 0.6 }, ]; export default async function sitemap(): Promise { @@ -22,14 +28,17 @@ export default async function sitemap(): Promise { const staticRoutes = STATIC_ROUTES.map((r) => ({ ...r, lastModified: now })); // Cap well under the 50k-URL sitemap limit; these are the freshest public - // reports we've server-rendered. + // reports we've server-rendered. Skip the demo report — it's already a static + // route above, so this avoids emitting it twice. const recent = await getRecentReports(10_000); - const reportRoutes: MetadataRoute.Sitemap = recent.map((r) => ({ - url: `${BASE}/analyze/${r.code}`, - lastModified: new Date(r.ts), - changeFrequency: "weekly", - priority: 0.5, - })); + const reportRoutes: MetadataRoute.Sitemap = recent + .filter((r) => r.code !== DEMO_REPORT.code) + .map((r) => ({ + url: `${BASE}/analyze/${r.code}`, + lastModified: new Date(r.ts), + changeFrequency: "weekly", + priority: 0.5, + })); return [...staticRoutes, ...reportRoutes]; }