mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Improve SEO of hack pages
This commit is contained in:
parent
410b3eae57
commit
cecfd49b91
40
package-lock.json
generated
40
package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
|||
"@headlessui/react": "^2.2.9",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.74.0",
|
||||
"@types/serialize-javascript": "^5.0.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"minio": "^8.0.6",
|
||||
|
|
@ -26,7 +27,9 @@
|
|||
"react-markdown": "9.0.3",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "4.0.0",
|
||||
"rom-patcher-js": "github:marcrobledo/RomPatcher.js#91e522e247f709e894761157ccba3189004d0859"
|
||||
"rom-patcher-js": "github:marcrobledo/RomPatcher.js#91e522e247f709e894761157ccba3189004d0859",
|
||||
"schema-dts": "^1.1.5",
|
||||
"serialize-javascript": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
@ -1453,6 +1456,12 @@
|
|||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serialize-javascript": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/serialize-javascript/-/serialize-javascript-5.0.4.tgz",
|
||||
"integrity": "sha512-Z2R7UKFuNWCP8eoa2o9e5rkD3hmWxx/1L0CYz0k2BZzGh0PhEVMp9kfGiqEml/0IglwNERXZ2hwNzIrSz/KHTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
|
|
@ -5018,6 +5027,12 @@
|
|||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/schema-dts": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz",
|
||||
"integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||
|
|
@ -5051,14 +5066,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"dev": true,
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.0.tgz",
|
||||
"integrity": "sha512-pgLqXJrPEahCSuSLFvmBbIi6C769CQkpG509G8lwnaltznys3QxinnJE71cr78TOr6OPi+boskt4NvRbhfgFrA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
|
|
@ -5478,6 +5491,17 @@
|
|||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin/node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"@headlessui/react": "^2.2.9",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.74.0",
|
||||
"@types/serialize-javascript": "^5.0.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"minio": "^8.0.6",
|
||||
|
|
@ -27,7 +28,9 @@
|
|||
"react-markdown": "9.0.3",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "4.0.0",
|
||||
"rom-patcher-js": "github:marcrobledo/RomPatcher.js#91e522e247f709e894761157ccba3189004d0859"
|
||||
"rom-patcher-js": "github:marcrobledo/RomPatcher.js#91e522e247f709e894761157ccba3189004d0859",
|
||||
"schema-dts": "^1.1.5",
|
||||
"serialize-javascript": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { baseRoms } from "@/data/baseRoms";
|
||||
import { baseRoms, PLATFORM_NAMES } from "@/data/baseRoms";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Gallery from "@/components/Hack/Gallery";
|
||||
|
|
@ -13,6 +13,9 @@ import { createClient } from "@/utils/supabase/server";
|
|||
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
|
||||
import HackOptionsMenu from "@/components/Hack/HackOptionsMenu";
|
||||
import DownloadsBadge from "@/components/Hack/DownloadsBadge";
|
||||
import type { CreativeWork, WithContext } from "schema-dts";
|
||||
import serialize from "serialize-javascript";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
interface HackDetailProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -23,14 +26,68 @@ export async function generateMetadata({ params }: HackDetailProps): Promise<Met
|
|||
const supabase = await createClient();
|
||||
const { data: hack } = await supabase
|
||||
.from("hacks")
|
||||
.select("title,summary,approved")
|
||||
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack || !hack.approved) return { title: "Hack not found" };
|
||||
const baseRomName = baseRoms.find((r) => r.id === hack.base_rom)?.name ?? "Pokémon";
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("username")
|
||||
.eq("id", hack.created_by as string)
|
||||
.maybeSingle();
|
||||
const author = profile?.username ? `@${profile.username}` : undefined;
|
||||
|
||||
// Build canonical and page URLs
|
||||
const hdrs = await headers();
|
||||
const siteBase = process.env.NEXT_PUBLIC_SITE_URL ? process.env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "") : "";
|
||||
const proto = siteBase ? "" : (hdrs.get("x-forwarded-proto") || "https");
|
||||
const host = siteBase ? "" : (hdrs.get("host") || "");
|
||||
const baseUrl = siteBase || (proto && host ? `${proto}://${host}` : "");
|
||||
const pageUrl = baseUrl ? `${baseUrl}/hack/${slug}` : `/hack/${slug}`;
|
||||
|
||||
const title = `${hack.title} hack download | A ${baseRomName} ROM fan game`;
|
||||
const description = `Play ${hack.title}, a fan-made Pokémon ROM hack for ${baseRomName}. ${hack.summary}`;
|
||||
|
||||
const keywords: string[] = [
|
||||
hack.title,
|
||||
`${hack.title} rom hack`,
|
||||
`${hack.title} patch`,
|
||||
`${hack.title} patcher`,
|
||||
`${hack.title} patched rom`,
|
||||
`${hack.title} rom download`,
|
||||
`${hack.title} download patch`,
|
||||
baseRomName,
|
||||
"Pokemon rom hack",
|
||||
"Pokemon patch file",
|
||||
`${baseRomName} rom hack`,
|
||||
`${baseRomName} patch file`,
|
||||
];
|
||||
|
||||
return {
|
||||
title: hack.title,
|
||||
description: hack.summary || undefined,
|
||||
};
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
alternates: {
|
||||
canonical: pageUrl,
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: pageUrl,
|
||||
authors: author ? [author] : undefined,
|
||||
type: "article",
|
||||
publishedTime: new Date(hack.created_at).toISOString(),
|
||||
modifiedTime: hack.updated_at ? new Date(hack.updated_at).toISOString() : undefined,
|
||||
images: hack.box_art ? [
|
||||
{
|
||||
url: hack.box_art,
|
||||
alt: `${hack.title} ROM hack box art`,
|
||||
},
|
||||
] : undefined,
|
||||
},
|
||||
} satisfies Metadata;
|
||||
}
|
||||
|
||||
export default async function HackDetail({ params }: HackDetailProps) {
|
||||
|
|
@ -82,6 +139,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
let patchVersion = "";
|
||||
let patchId: number | null = null;
|
||||
let lastUpdated: string | null = null;
|
||||
let patchCreatedAt: string | null = null;
|
||||
if (hack.current_patch != null) {
|
||||
const { data: patch } = await supabase
|
||||
.from("patches")
|
||||
|
|
@ -95,11 +153,65 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
patchVersion = patch.version || "";
|
||||
patchId = patch.id;
|
||||
lastUpdated = new Date(patch.created_at).toLocaleDateString();
|
||||
patchCreatedAt = patch.created_at;
|
||||
}
|
||||
}
|
||||
|
||||
// Build canonical URL, sameAs, dates, and JSON-LD
|
||||
const hdrs = await headers();
|
||||
const siteBase = process.env.NEXT_PUBLIC_SITE_URL ? process.env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "") : "";
|
||||
const proto = siteBase ? "" : (hdrs.get("x-forwarded-proto") || "https");
|
||||
const host = siteBase ? "" : (hdrs.get("host") || "");
|
||||
const baseUrl = siteBase || (proto && host ? `${proto}://${host}` : "");
|
||||
const pageUrl = baseUrl ? `${baseUrl}/hack/${hack.slug}` : `/hack/${hack.slug}`;
|
||||
|
||||
const authorName = profile?.username || "Unknown";
|
||||
|
||||
const sameAs: string[] = [];
|
||||
const social = (hack.social_links as unknown) as { discord?: string; twitter?: string; pokecommunity?: string } | null;
|
||||
if (social?.discord) sameAs.push(social.discord);
|
||||
if (social?.twitter) sameAs.push(social.twitter);
|
||||
if (social?.pokecommunity) sameAs.push(social.pokecommunity);
|
||||
|
||||
const dateCreated = new Date(hack.created_at).toISOString();
|
||||
const modifiedRaw = patchCreatedAt || (hack.updated_at as string) || (hack.created_at as string);
|
||||
const dateModified = new Date(modifiedRaw).toISOString();
|
||||
// Add common tags to keywords
|
||||
const commonTags = ["Pokémon", "ROM Hack", "Patch", "BPS", "Romhack", "Pokemon", "Mod", "Game", "Hack"];
|
||||
if (baseRom) commonTags.push(PLATFORM_NAMES[baseRom.platform], baseRom.platform, baseRom.name);
|
||||
const keywords = tags.length ? [...tags, ...commonTags] : commonTags;
|
||||
|
||||
const jsonLd: WithContext<CreativeWork> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CreativeWork',
|
||||
name: hack.title,
|
||||
description: hack.summary || undefined,
|
||||
url: pageUrl || undefined,
|
||||
mainEntityOfPage: pageUrl || undefined,
|
||||
image: images.length ? images : undefined,
|
||||
thumbnailUrl: images.length ? images[0] : hack.box_art || undefined,
|
||||
author: { '@type': 'Person', name: authorName },
|
||||
sameAs: sameAs.length ? sameAs : undefined,
|
||||
genre: "Game Mod",
|
||||
dateCreated,
|
||||
dateModified,
|
||||
keywords: keywords,
|
||||
version: patchVersion || undefined,
|
||||
inLanguage: 'en',
|
||||
isAccessibleForFree: true,
|
||||
isBasedOn: baseRom ? {
|
||||
'@type': 'VideoGame',
|
||||
name: baseRom.name,
|
||||
gamePlatform: PLATFORM_NAMES[baseRom.platform],
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-lg w-full pb-28">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serialize(jsonLd, { isJSON: true }) }}
|
||||
/>
|
||||
<HackActions
|
||||
title={hack.title}
|
||||
version={patchVersion || "Pre-release"}
|
||||
|
|
@ -144,9 +256,22 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
<Gallery images={images} title={hack.title} />
|
||||
|
||||
<div className="card p-5">
|
||||
<h2 className="text-xl font-semibold tracking-tight">About this hack</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">About this hack</h2>
|
||||
<div className="prose prose-sm mt-3 max-w-none text-foreground/80">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{hack.description}</ReactMarkdown>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={{
|
||||
h1: 'h2',
|
||||
h2: 'h3',
|
||||
h3: 'h4',
|
||||
h4: 'h5',
|
||||
h5: 'h6',
|
||||
h6: 'h6',
|
||||
}}
|
||||
>
|
||||
{hack.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -198,6 +323,18 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
|
||||
<p>
|
||||
This page provides the official patch file for <span className="font-semibold">{hack.title}</span>. You can safely download the patched ROM for this hack
|
||||
using our built-in patcher.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
By pressing the "Patch Now" button, your browser will apply the downloaded <span className="font-semibold">Fire of Sky</span> .bps patch file to your legally-obtained <span className="font-semibold">{baseRom?.name}</span> ROM. The patched ROM will then be automatically downloaded.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
|||
default: "Hackdex",
|
||||
template: "%s | Hackdex",
|
||||
},
|
||||
description: "Discover and share Pokémon romhack patches.",
|
||||
description: "Discover and download Pokémon rom hacks.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ export const PLATFORMS = [
|
|||
] as const;
|
||||
export type Platform = typeof PLATFORMS[number];
|
||||
|
||||
export const PLATFORM_NAMES: Record<Platform, string> = {
|
||||
GB: "Game Boy",
|
||||
GBC: "Game Boy Color",
|
||||
GBA: "Game Boy Advance",
|
||||
NDS: "Nintendo DS",
|
||||
};
|
||||
|
||||
export type BaseRom = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user