diff --git a/package-lock.json b/package-lock.json index 84412ab..1cd8376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6aa8d33..315ec14 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index cbbbc34..b9f5ee4 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -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 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 = { + '@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 (
+