Nye-TeeOff/frontend/src/app/seo.ts

224 lines
5.6 KiB
TypeScript
Raw Normal View History

2026-04-12 22:07:51 +02:00
import type { Metadata } from "next";
import { FALLBACK_IMAGE } from "@/config/constants";
import type { FacilityRecord } from "@/app/facilityData";
type MetadataInput = {
title: string;
description: string;
path?: string;
image?: string | null;
type?: "website" | "article";
};
type BreadcrumbItem = {
name: string;
path: string;
};
type CollectionPageInput = {
name: string;
description: string;
path: string;
};
type SocialLink = {
url?: string | null;
};
type FacilitySeoRecord = FacilityRecord & {
address?: string | null;
email?: string | null;
social_links?: unknown;
};
export const SITE_NAME = "TeeOff";
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://nye.teeoff.no").replace(/\/$/, "");
export const DEFAULT_DESCRIPTION =
"Oppdatert banestatus, priser, Veien til Golf og informasjon om norske golfanlegg samlet på ett sted.";
export const DEFAULT_OG_IMAGE = buildAbsoluteUrl(FALLBACK_IMAGE);
export const ORGANIZATION_ID = `${SITE_URL}#organization`;
export const WEBSITE_ID = `${SITE_URL}#website`;
const SAME_AS_LINKS = [
"https://www.facebook.com/TeeOff.norge/",
"https://twitter.com/TeeOffno",
"https://www.instagram.com/teeoffno/",
];
export function buildAbsoluteUrl(path = "/") {
if (!path) return SITE_URL;
if (/^https?:\/\//i.test(path)) return path;
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
export function resolveImageUrl(image?: string | null) {
if (!image) return DEFAULT_OG_IMAGE;
return buildAbsoluteUrl(image);
}
export function stripHtml(value: string | null | undefined) {
return String(value || "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
export function trimDescription(value: string | null | undefined, maxLength = 160) {
const plain = stripHtml(value);
if (plain.length <= maxLength) return plain;
return `${plain.slice(0, maxLength - 1).trimEnd()}`;
}
export function createPageMetadata({
title,
description,
path = "/",
image,
type = "website",
}: MetadataInput): Metadata {
const ogImage = resolveImageUrl(image);
return {
title,
description,
alternates: {
canonical: path,
},
openGraph: {
type,
locale: "nb_NO",
siteName: SITE_NAME,
title,
description,
url: buildAbsoluteUrl(path),
images: [
{
url: ogImage,
width: 1600,
height: 900,
alt: title,
},
],
},
twitter: {
card: "summary_large_image",
site: "@TeeOffno",
creator: "@TeeOffno",
title,
description,
images: [ogImage],
},
};
}
export function createOrganizationJsonLd() {
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": ORGANIZATION_ID,
name: SITE_NAME,
url: SITE_URL,
logo: {
"@type": "ImageObject",
url: buildAbsoluteUrl("/icons/cropped-siteicon-1.png"),
},
sameAs: SAME_AS_LINKS,
};
}
export function createWebsiteJsonLd() {
return {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": WEBSITE_ID,
url: SITE_URL,
name: SITE_NAME,
inLanguage: "nb-NO",
publisher: {
"@id": ORGANIZATION_ID,
},
};
}
export function createBreadcrumbJsonLd(items: BreadcrumbItem[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: buildAbsoluteUrl(item.path),
})),
};
}
export function createCollectionPageJsonLd({ name, description, path }: CollectionPageInput) {
return {
"@context": "https://schema.org",
"@type": "CollectionPage",
name,
description,
url: buildAbsoluteUrl(path),
inLanguage: "nb-NO",
isPartOf: {
"@id": WEBSITE_ID,
},
about: {
"@id": ORGANIZATION_ID,
},
};
}
export function createFacilityJsonLd(facility: FacilitySeoRecord) {
const socialLinks = parseJson<SocialLink[]>(facility.social_links, []);
const sameAs = [facility.website_url, ...socialLinks.map((entry) => entry?.url || null)].filter(Boolean);
return {
"@context": "https://schema.org",
"@type": "GolfCourse",
"@id": buildAbsoluteUrl(`/golfbaner/${facility.slug}#golfcourse`),
name: facility.name,
description:
trimDescription(facility.description) ||
`${facility.name} er et golfanlegg på TeeOff med oppdatert banestatus og praktisk klubbinfo.`,
url: buildAbsoluteUrl(`/golfbaner/${facility.slug}`),
image: resolveImageUrl(facility.image_url),
telephone: facility.phone || undefined,
email: facility.email || undefined,
address:
facility.address || facility.city || facility.county
? {
"@type": "PostalAddress",
streetAddress: facility.address || undefined,
addressLocality: facility.city || undefined,
addressRegion: facility.county || undefined,
addressCountry: "NO",
}
: undefined,
geo:
typeof facility.lat === "number" && typeof facility.lng === "number"
? {
"@type": "GeoCoordinates",
latitude: facility.lat,
longitude: facility.lng,
}
: undefined,
sameAs: sameAs.length > 0 ? sameAs : undefined,
isPartOf: {
"@id": WEBSITE_ID,
},
};
}
function parseJson<T>(value: unknown, fallback: T): T {
if (!value) return fallback;
if (typeof value === "object") return value as T;
try {
return JSON.parse(String(value)) as T;
} catch {
return fallback;
}
}