222 lines
5.1 KiB
TypeScript
222 lines
5.1 KiB
TypeScript
import type { Metadata } from "next"
|
|
import { businessConfig } from "@/lib/seo-config"
|
|
import {
|
|
buildAbsoluteUrl,
|
|
getSeoPageDefinition,
|
|
ogImagePath,
|
|
type StaticSeoPageKey,
|
|
} from "@/lib/seo-registry"
|
|
|
|
/**
|
|
* Clean HTML entities from text (e.g., & -> &, " -> ")
|
|
*/
|
|
function cleanHtmlEntities(text: string): string {
|
|
if (!text) return ""
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/ /g, " ")
|
|
.trim()
|
|
}
|
|
|
|
export interface SEOData {
|
|
title: string
|
|
description: string
|
|
excerpt?: string
|
|
date?: string
|
|
modified?: string
|
|
image?: string
|
|
robots?: Metadata["robots"]
|
|
path?: string
|
|
keywords?: string[]
|
|
}
|
|
|
|
function createRobotsValue(noindex?: boolean): Metadata["robots"] | undefined {
|
|
if (!noindex) {
|
|
return undefined
|
|
}
|
|
|
|
return {
|
|
index: false,
|
|
follow: false,
|
|
googleBot: {
|
|
index: false,
|
|
follow: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate SEO metadata for a WordPress page/post
|
|
*/
|
|
export function generateSEOMetadata(data: SEOData): Metadata {
|
|
const {
|
|
title,
|
|
description,
|
|
excerpt,
|
|
date,
|
|
modified,
|
|
image,
|
|
robots,
|
|
path,
|
|
keywords,
|
|
} = data
|
|
|
|
const cleanTitle = cleanHtmlEntities(title)
|
|
const fullTitle = cleanTitle.includes(businessConfig.name)
|
|
? cleanTitle
|
|
: `${cleanTitle} | ${businessConfig.name}`
|
|
let seoDescription = cleanHtmlEntities(description || excerpt || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
|
|
if (!seoDescription) {
|
|
seoDescription =
|
|
"Rocky Mountain Vending provides vending machine placement, service, repairs, and support across Utah."
|
|
}
|
|
|
|
const ogImage = image || ogImagePath
|
|
const canonicalUrl = path ? buildAbsoluteUrl(path) : undefined
|
|
|
|
const openGraph: NonNullable<Metadata["openGraph"]> = {
|
|
title: fullTitle,
|
|
description: seoDescription,
|
|
type: date || modified ? "article" : "website",
|
|
...(canonicalUrl ? { url: canonicalUrl } : {}),
|
|
images: [
|
|
{
|
|
url: ogImage,
|
|
width: 1200,
|
|
height: 630,
|
|
alt: cleanTitle,
|
|
},
|
|
],
|
|
siteName: businessConfig.name,
|
|
...(date && { publishedTime: date }),
|
|
...(modified && { modifiedTime: modified }),
|
|
}
|
|
|
|
const metadata: Metadata = {
|
|
title: fullTitle,
|
|
description: seoDescription,
|
|
keywords,
|
|
openGraph,
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title: fullTitle,
|
|
description: seoDescription,
|
|
images: [ogImage],
|
|
},
|
|
alternates: canonicalUrl ? { canonical: canonicalUrl } : undefined,
|
|
robots,
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
export function generateRegistryMetadata(
|
|
key: StaticSeoPageKey,
|
|
overrides?: Partial<SEOData>
|
|
): Metadata {
|
|
const page = getSeoPageDefinition(key)
|
|
|
|
return generateSEOMetadata({
|
|
title: overrides?.title ?? page.title,
|
|
description: overrides?.description ?? page.description,
|
|
excerpt: overrides?.excerpt,
|
|
date: overrides?.date,
|
|
modified: overrides?.modified,
|
|
image: overrides?.image,
|
|
robots: overrides?.robots ?? createRobotsValue(page.noindex),
|
|
path: overrides?.path ?? page.canonicalPath ?? page.path,
|
|
keywords: overrides?.keywords ?? [...page.keywords],
|
|
})
|
|
}
|
|
|
|
export function generateRegistryStructuredData(
|
|
key: StaticSeoPageKey,
|
|
overrides?: {
|
|
title?: string
|
|
description?: string
|
|
url?: string
|
|
datePublished?: string
|
|
dateModified?: string
|
|
type?: "Article" | "WebPage"
|
|
}
|
|
) {
|
|
const page = getSeoPageDefinition(key)
|
|
|
|
return generateStructuredData({
|
|
title: overrides?.title ?? page.title,
|
|
description: overrides?.description ?? page.description,
|
|
url: overrides?.url ?? buildAbsoluteUrl(page.canonicalPath ?? page.path),
|
|
datePublished: overrides?.datePublished,
|
|
dateModified: overrides?.dateModified,
|
|
type: overrides?.type,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Generate structured data (JSON-LD) for a page
|
|
*/
|
|
export function generateStructuredData(data: {
|
|
title: string
|
|
description: string
|
|
url: string
|
|
datePublished?: string
|
|
dateModified?: string
|
|
type?: "Article" | "WebPage"
|
|
}) {
|
|
const {
|
|
title,
|
|
description,
|
|
url,
|
|
datePublished,
|
|
dateModified,
|
|
type = "WebPage",
|
|
} = data
|
|
|
|
const structuredData: Record<string, unknown> = {
|
|
"@context": "https://schema.org",
|
|
"@type": type,
|
|
headline: cleanHtmlEntities(title),
|
|
description: cleanHtmlEntities(description),
|
|
url: url,
|
|
}
|
|
|
|
if (datePublished) {
|
|
structuredData.datePublished = datePublished
|
|
}
|
|
if (dateModified) {
|
|
structuredData.dateModified = dateModified
|
|
}
|
|
|
|
if (type === "Article") {
|
|
structuredData.author = {
|
|
"@type": "Organization",
|
|
name: businessConfig.name,
|
|
url: businessConfig.website,
|
|
}
|
|
structuredData.publisher = {
|
|
"@type": "Organization",
|
|
name: businessConfig.name,
|
|
legalName: businessConfig.legalName,
|
|
url: businessConfig.website,
|
|
logo: {
|
|
"@type": "ImageObject",
|
|
url: `${businessConfig.website}/rmv-logo.png`,
|
|
width: 180,
|
|
height: 45,
|
|
},
|
|
}
|
|
structuredData.mainEntityOfPage = {
|
|
"@type": "WebPage",
|
|
"@id": url,
|
|
}
|
|
}
|
|
|
|
return structuredData
|
|
}
|