Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions app/components/FeaturedReports.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof r> => r !== null)
.slice(0, MAX_SHOWN);

if (shown.length === 0) return null;

return (
<div className="space-y-3">
<h2 className="text-lg font-semibold text-foreground">
Recently Analyzed Reports
</h2>
<div className="grid gap-2">
{shown.map(({ code, meta }) => {
const h = headline(meta.title, meta.zone);
return (
<Link
key={code}
href={`/analyze/${code}`}
className="surface-card rounded-lg px-4 py-3 transition-interactive hover:bg-surface-2 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{h} — WoW Classic Raid Analysis
</p>
<p className="text-caption text-muted-foreground truncate">
{meta.zone} &middot; {meta.players.length} raiders
</p>
</div>
<span className="text-caption text-gold-from shrink-0">
View &rarr;
</span>
</Link>
);
})}
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -74,6 +92,29 @@ export default function Home() {
Lich King, Cataclysm Classic, and Anniversary realms.
</p>
</div>

<FeaturedReports />

<div className="space-y-3">
<h2 className="text-lg font-semibold text-foreground">Guides</h2>
<ul className="space-y-2 text-sm leading-relaxed">
{GUIDES.map((g) => (
<li key={g.href}>
<Link
href={g.href}
className="text-gold-from hover:underline"
>
{g.title}
</Link>
</li>
))}
<li>
<Link href="/guides" className="text-muted-foreground hover:text-foreground">
All guides &rarr;
</Link>
</li>
</ul>
</div>
</section>
</main>
);
Expand Down
23 changes: 16 additions & 7 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -15,21 +16,29 @@ 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<MetadataRoute.Sitemap> {
const now = new Date();
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];
}