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];
}