Nye-TeeOff/frontend/src/content/editorialArticles.ts

720 lines
21 KiB
TypeScript
Raw Normal View History

import { existsSync } from "node:fs";
import path from "node:path";
import { cookies } from "next/headers";
import { API_URL } from "@/config/constants";
export type ArticleSection = "banebesok" | "meninger";
export type CourseVisitImage = {
src: string;
alt: string;
caption: string;
};
export type ArticleMediaItem = {
id: string;
type: "image" | "video";
src: string;
alt: string;
caption: string;
poster?: string;
};
export type CourseVisitFact = {
label: string;
value: string;
href?: string;
};
export type CourseVisitBodyBlock =
| {
type: "richText";
title?: string;
html: string;
}
| {
type: "quote";
quote: string;
attribution?: string;
}
| {
type: "checklist";
title: string;
items: string[];
}
| {
type: "factGrid";
title: string;
items: CourseVisitFact[];
}
| {
type: "callout";
title: string;
body: string;
};
export type EditorialArticle = {
section: ArticleSection;
slug: string;
eyebrow: string;
title: string;
description: string;
excerpt: string;
locationLabel: string;
facilityName?: string;
facilitySlug?: string;
publishedAt: string;
updatedAt?: string;
readingTime: string;
heroImages: CourseVisitImage[];
mediaGallery: ArticleMediaItem[];
featuredMediaId?: string;
quickFacts: CourseVisitFact[];
highlights: string[];
blocks: CourseVisitBodyBlock[];
sourceUrl?: string;
sourceLabel?: string;
status: "draft" | "published";
isPreview?: boolean;
};
type ArticleApiRecord = {
id?: number;
section?: string | null;
status?: string | null;
slug: string;
title: string;
description?: string | null;
excerpt?: string | null;
eyebrow?: string | null;
location_label?: string | null;
facility_name?: string | null;
facility_slug?: string | null;
author_name?: string | null;
hero_images?: CourseVisitImage[] | null;
media_gallery?: ArticleMediaItem[] | null;
featured_media_id?: string | null;
content_html?: string | null;
source_url?: string | null;
source_label?: string | null;
published_at?: string | null;
updated_at?: string | null;
};
type FacilityMeta = {
name: string;
region: string;
};
const facilityMetaBySlug: Record<string, FacilityMeta> = {
"lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" },
"kjekstad-golfklubb": { name: "Kjekstad Golfklubb", region: "Buskerud" },
"kragero-golfklubb": { name: "Kragerø Golfklubb", region: "Telemark" },
"egersund-golfklubb": { name: "Egersund Golfklubb", region: "Rogaland" },
"tyrifjord-golfklubb": { name: "Tyrifjord Golfklubb", region: "Buskerud" },
"kongsvingers-golfklubb": { name: "Kongsvingers Golfklubb", region: "Innlandet" },
"drammen-golfklubb": { name: "Drammen Golfklubb", region: "Buskerud" },
};
const teeoffInternalLinkPattern = /https?:\/\/teeoff\.no\/([^"'#?\s>]+)/gi;
const imageTagPattern = /<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*\balt=['"]([^'"]*)['"][^>]*>/gi;
const imageTagWithoutAltPattern = /<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*>/gi;
const paragraphWithImagePattern = /<p\b[^>]*>(?:(?!<p\b)[\s\S])*?<img\b[\s\S]*?<\/p>/gi;
const anchorWrappedImagePattern = /<a\b[^>]*>\s*<img\b[^>]*>\s*<\/a>/gi;
const standaloneImagePattern = /<img\b[^>]*>\s*(?:<br\s*\/?>)?/gi;
const legacyMediaUrlPattern =
/^https?:\/\/(?:www\.)?(?:teeoff\.no|nye\.teeoff\.no|wp\.teeoff\.no)(\/(?:wp-content\/uploads|uploads\/articles)\/.+)$/i;
const youtubeThumbnailPattern = /^https?:\/\/i\.ytimg\.com\/vi(?:_webp)?\/([^/]+)\//i;
const disallowedSegments = new Set(["wp-content", "wp-json", "meninger", "category", "author", "tag", "feed"]);
function normalizeSection(value?: string | null): ArticleSection {
return String(value || "").trim().toLowerCase() === "meninger" ? "meninger" : "banebesok";
}
function normalizeStatus(value?: string | null): "draft" | "published" {
return String(value || "").trim().toLowerCase() === "published" ? "published" : "draft";
}
export function buildEditorialPath(section: ArticleSection, slug: string) {
return `/${section}/${slug}`;
}
function getSectionLabel(section: ArticleSection) {
return section === "meninger" ? "Meninger" : "Banebesøk";
}
function decodeEntities(value: string) {
return value
.replace(/&#8230;/g, "...")
.replace(/&hellip;/g, "...")
.replace(/&nbsp;/g, " ")
.replace(/&laquo;/g, "«")
.replace(/&raquo;/g, "»")
.replace(/&#038;/g, "&")
.replace(/&amp;/g, "&");
}
function stripHtml(value: string) {
return decodeEntities(value).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("nb-NO", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(value));
}
function getReadingTime(html: string) {
const wordCount = stripHtml(html).split(/\s+/).filter(Boolean).length;
const minutes = Math.max(3, Math.round(wordCount / 220));
return `${minutes} min`;
}
function normalizeAuthorName(value?: string | null) {
const normalized = String(value || "").trim();
if (!normalized) {
return "TeeOff";
}
return /haagenrud/i.test(normalized) ? "TeeOff" : normalized;
}
function getFacilityMeta(slug: string) {
return facilityMetaBySlug[slug] || {
name: slug
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" "),
region: "Norge",
};
}
function normalizeInternalLinks(html: string) {
return html.replace(teeoffInternalLinkPattern, (fullMatch, rawPath: string) => {
const path = rawPath.split("?")[0].replace(/\/+$/, "");
const segments = path.split("/").filter(Boolean);
if (segments.length === 0 || disallowedSegments.has(segments[0])) {
return fullMatch;
}
const candidate = segments[segments.length - 1];
if (!candidate.includes("golf")) {
return fullMatch;
}
return `/golfbaner/${candidate}`;
});
}
function normalizeMediaUrl(src: string) {
const trimmed = String(src || "").trim();
if (!trimmed) return "";
const legacyMatch = trimmed.match(legacyMediaUrlPattern);
if (legacyMatch) {
const mediaPath = legacyMatch[1];
if (mediaPath.startsWith("/wp-content/uploads/")) {
const localCandidate = path.join(process.cwd(), "public", mediaPath.slice(1));
if (existsSync(localCandidate)) {
return mediaPath;
}
return `https://wp.teeoff.no${mediaPath}`;
}
return mediaPath;
}
return trimmed;
}
function escapeHtmlAttribute(value: string) {
return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function normalizeSrcsetValue(value: string) {
return value
.split(",")
.map((candidate) => {
const trimmed = candidate.trim();
if (!trimmed) return "";
const [url, ...descriptor] = trimmed.split(/\s+/);
return [normalizeMediaUrl(url), ...descriptor].join(" ").trim();
})
.filter(Boolean)
.join(", ");
}
function normalizeMediaAttributes(html: string) {
return html
.replace(/\b(src|href|poster)=["']([^"']+)["']/gi, (_match, attribute: string, value: string) => {
return `${attribute}="${escapeHtmlAttribute(normalizeMediaUrl(value))}"`;
})
.replace(/\bsrcset=["']([^"']+)["']/gi, (_match, value: string) => {
return `srcset="${escapeHtmlAttribute(normalizeSrcsetValue(value))}"`;
});
}
function buildInlineYouTubeEmbed(videoId: string, title: string) {
return `<div class="course-visit-inline-video"><iframe src="https://www.youtube.com/embed/${videoId}?rel=0" title="${escapeHtmlAttribute(title)}" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy"></iframe></div>`;
}
function replaceYoutubeThumbnailImages(html: string, articleTitle: string) {
return html.replace(/<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*>/gi, (match, rawSrc: string) => {
const youtubeMatch = normalizeMediaUrl(rawSrc).match(youtubeThumbnailPattern);
if (!youtubeMatch) {
return match;
}
return buildInlineYouTubeEmbed(youtubeMatch[1], articleTitle);
});
}
function prepareRichTextHtml(html: string, articleTitle: string) {
return normalizeMediaAttributes(replaceYoutubeThumbnailImages(html, articleTitle))
.replace(/(?:<p>\s*(?:&nbsp;|\s|<br\s*\/?>)*<\/p>\s*)+/gi, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function buildMediaId(type: "image" | "video", src: string) {
const normalized = `${type}:${normalizeMediaUrl(src)}`;
let hash = 0;
for (let index = 0; index < normalized.length; index += 1) {
hash = (hash * 31 + normalized.charCodeAt(index)) >>> 0;
}
return `${type}-${hash.toString(16)}`;
}
function stripImageMarkup(value: string) {
return value
.replace(anchorWrappedImagePattern, " ")
.replace(standaloneImagePattern, " ")
.replace(/<a\b[^>]*>\s*<\/a>/gi, " ")
.replace(/<br\s*\/?>/gi, " ");
}
function captionFromHtmlFragment(html: string, fallback: string) {
const caption = stripHtml(stripImageMarkup(html)).replace(/\s+/g, " ").trim();
return caption || fallback;
}
function extractMediaFromHtml(html: string, articleTitle: string) {
const mediaItems: ArticleMediaItem[] = [];
const seen = new Set<string>();
for (const block of html.match(paragraphWithImagePattern) || []) {
const blockCaption = captionFromHtmlFragment(block, articleTitle);
for (const match of block.matchAll(imageTagPattern)) {
const src = normalizeMediaUrl(match[1]);
const alt = decodeEntities(match[2] || "").trim();
if (!src || seen.has(src) || (!src.includes("/wp-content/uploads/") && !src.includes("i.ytimg.com"))) {
continue;
}
seen.add(src);
const youtubeMatch = src.match(youtubeThumbnailPattern);
if (youtubeMatch) {
const videoId = youtubeMatch[1];
mediaItems.push({
id: buildMediaId("video", videoId),
type: "video",
src: `https://www.youtube.com/watch?v=${videoId}`,
alt: alt || articleTitle,
caption:
blockCaption && blockCaption !== articleTitle
? blockCaption
: alt && alt !== "youtube-video-thumbnail"
? alt
: "Video",
poster: src,
});
continue;
}
mediaItems.push({
id: buildMediaId("image", src),
type: "image",
src,
alt: alt || articleTitle,
caption: blockCaption || alt || articleTitle,
});
}
}
for (const match of html.matchAll(imageTagPattern)) {
const src = normalizeMediaUrl(match[1]);
const alt = decodeEntities(match[2] || "").trim();
if (!src || seen.has(src) || (!src.includes("/wp-content/uploads/") && !src.includes("i.ytimg.com"))) {
continue;
}
seen.add(src);
const youtubeMatch = src.match(youtubeThumbnailPattern);
if (youtubeMatch) {
const videoId = youtubeMatch[1];
mediaItems.push({
id: buildMediaId("video", videoId),
type: "video",
src: `https://www.youtube.com/watch?v=${videoId}`,
alt: alt || articleTitle,
caption: alt && alt !== "youtube-video-thumbnail" ? alt : "Video",
poster: src,
});
continue;
}
mediaItems.push({
id: buildMediaId("image", src),
type: "image",
src,
alt: alt || articleTitle,
caption: alt || articleTitle,
});
}
if (mediaItems.length === 0) {
for (const match of html.matchAll(imageTagWithoutAltPattern)) {
const src = normalizeMediaUrl(match[1]);
if (!src || seen.has(src) || !src.includes("/wp-content/uploads/")) {
continue;
}
seen.add(src);
mediaItems.push({
id: buildMediaId("image", src),
type: "image",
src,
alt: articleTitle,
caption: articleTitle,
});
}
}
return mediaItems;
}
function deriveMediaGalleryFromHeroImages(images: CourseVisitImage[]) {
return images
.filter((image) => Boolean(image?.src))
.map((image) => ({
id: buildMediaId("image", image.src),
type: "image" as const,
src: normalizeMediaUrl(image.src),
alt: image.alt || "",
caption: image.caption || image.alt || "",
poster: "",
}));
}
function sanitizeMediaGallery(items: ArticleMediaItem[], title: string) {
const seen = new Set<string>();
return items
.filter((item): item is ArticleMediaItem => Boolean(item?.src && item?.type))
.map((item) => ({
id: item.id || buildMediaId(item.type, item.src),
type: item.type,
src: normalizeMediaUrl(item.src),
alt: item.alt || title,
caption: item.caption || item.alt || title,
poster: item.poster ? normalizeMediaUrl(item.poster) : "",
}))
.filter((item) => {
const key = `${item.type}:${item.src}`;
if (!item.src || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function buildHeroImagesFromMedia(mediaGallery: ArticleMediaItem[], title: string, featuredMediaId?: string | null) {
const imageItems = mediaGallery
.filter((item) => item.type === "image")
.map((item) => ({
src: item.src,
alt: item.alt || title,
caption: item.caption || item.alt || title,
id: item.id,
}));
if (featuredMediaId) {
const featuredIndex = imageItems.findIndex((item) => item.id === featuredMediaId);
if (featuredIndex > 0) {
const [featuredItem] = imageItems.splice(featuredIndex, 1);
imageItems.unshift(featuredItem);
}
}
return imageItems.map(({ id: _id, ...image }) => image);
}
function buildQuickFacts(args: {
facilityName?: string;
facilitySlug?: string;
publishedAt?: string;
authorName?: string;
}) {
const facts: CourseVisitFact[] = [];
if (args.facilityName && args.facilitySlug) {
facts.push({
label: "Baneprofil",
value: args.facilityName,
href: `/golfbaner/${args.facilitySlug}`,
});
}
if (args.publishedAt) {
facts.push({
label: "Publisert",
value: formatDate(args.publishedAt),
});
} else {
facts.push({
label: "Publisert",
value: "Ikke datert",
});
}
facts.push({
label: "Forfatter",
value: normalizeAuthorName(args.authorName),
});
return facts;
}
function buildHighlights(section: ArticleSection, facilityName?: string) {
const highlights = [
"Lagret som redaksjonell artikkel i TeeOff-admin.",
"Kan redigeres videre som HTML uten å miste artikkeloppsettet.",
];
if (facilityName) {
highlights.unshift(`Koblet til dagens baneprofil for ${facilityName}.`);
} else if (section === "meninger") {
highlights.unshift("Står på egne ben uten krav om kobling til baneprofil.");
} else {
highlights.unshift("Banebesøk uten baneprofilkobling kan nå publiseres som egne artikler.");
}
if (section === "meninger") {
highlights.push("Brukes for redaksjonelle artikler, siste nytt og andre meningsposter.");
} else {
highlights.push("Beholder mobilvennlig hero, faktaboks og langlesingsstruktur.");
}
return highlights;
}
function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
const section = normalizeSection(entry.section);
const facilitySlug = String(entry.facility_slug || "").trim() || undefined;
const facilityMeta = facilitySlug ? getFacilityMeta(facilitySlug) : null;
const facilityName = String(entry.facility_name || "").trim() || facilityMeta?.name || undefined;
const locationLabel = String(entry.location_label || "").trim() || facilityMeta?.region || "Norge";
const normalizedHtml = normalizeInternalLinks(String(entry.content_html || ""));
const preparedHtml = prepareRichTextHtml(normalizedHtml, entry.title);
const extractedMedia = extractMediaFromHtml(normalizedHtml, entry.title);
const dbImages = Array.isArray(entry.hero_images) ? entry.hero_images : [];
const dbMedia = Array.isArray(entry.media_gallery) ? entry.media_gallery : [];
const fallbackMedia = deriveMediaGalleryFromHeroImages(dbImages);
const mediaGallery = sanitizeMediaGallery(
dbMedia.length > 0 ? [...dbMedia, ...extractedMedia] : [...fallbackMedia, ...extractedMedia],
entry.title,
).slice(0, 24);
const featuredMediaId =
String(entry.featured_media_id || "").trim() ||
mediaGallery.find((item) => item.type === "image")?.id;
const heroImages = buildHeroImagesFromMedia(mediaGallery, entry.title, featuredMediaId).slice(0, 6);
const publishedAt = String(entry.published_at || entry.updated_at || "").trim();
const excerpt =
String(entry.excerpt || "").trim() ||
String(entry.description || "").trim() ||
stripHtml(normalizedHtml).slice(0, 220);
return {
section,
slug: entry.slug,
eyebrow: String(entry.eyebrow || "").trim() || getSectionLabel(section),
title: entry.title,
description: String(entry.description || "").trim() || excerpt,
excerpt,
locationLabel,
facilityName,
facilitySlug,
publishedAt,
updatedAt: String(entry.updated_at || "").trim() || undefined,
readingTime: getReadingTime(preparedHtml),
heroImages:
heroImages.length > 0
? heroImages
: [
{
src: "/Toppbilde-standard.jpg",
alt: entry.title,
caption: entry.title,
},
],
mediaGallery,
featuredMediaId,
quickFacts: buildQuickFacts({
facilityName,
facilitySlug,
publishedAt,
authorName: String(entry.author_name || "").trim() || undefined,
}),
highlights: buildHighlights(section, facilityName),
blocks: [
{
type: "richText",
html: preparedHtml,
},
],
status: normalizeStatus(entry.status),
isPreview: false,
};
}
async function fetchPublishedArticles(section: ArticleSection) {
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
if (!response.ok) {
return null;
}
const data = await response.json();
if (!Array.isArray(data)) {
return null;
}
return data.map((entry) => mapApiArticle(entry as ArticleApiRecord));
}
async function fetchPublishedFacilityArticles(section: ArticleSection, facilitySlug: string, limit = 3) {
const params = new URLSearchParams({
section,
facility_slug: facilitySlug,
limit: String(limit),
});
const response = await fetch(`${API_URL}/articles?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
return null;
}
const data = await response.json();
if (!Array.isArray(data)) {
return null;
}
return data.map((entry) => mapApiArticle(entry as ArticleApiRecord));
}
async function fetchPublishedArticleBySlug(slug: string, section: ArticleSection) {
const response = await fetch(`${API_URL}/articles/${slug}?section=${section}`, { cache: "no-store" });
if (!response.ok) {
return null;
}
const data = await response.json();
return mapApiArticle(data as ArticleApiRecord);
}
async function fetchAdminPreviewArticleBySlug(slug: string, section: ArticleSection) {
const cookieStore = await cookies();
const adminSession = cookieStore.get("admin_session")?.value;
if (!adminSession) {
return null;
}
const response = await fetch(`${API_URL}/admin/articles/by-slug/${slug}?section=${section}`, {
cache: "no-store",
headers: {
Cookie: `admin_session=${adminSession}`,
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
return {
...mapApiArticle(data as ArticleApiRecord),
isPreview: true,
};
}
export async function getEditorialArticles(section: ArticleSection) {
try {
const mapped = await fetchPublishedArticles(section);
if (mapped) {
return mapped;
}
} catch {
// Returnerer tom liste dersom API-et ikke er tilgjengelig.
}
return [];
}
export async function getEditorialArticleBySlug(
slug: string,
section: ArticleSection,
options?: { preview?: boolean },
) {
if (options?.preview) {
try {
const previewArticle = await fetchAdminPreviewArticleBySlug(slug, section);
if (previewArticle) {
return previewArticle;
}
} catch {
// Fortsetter til publisert oppslag dersom preview-oppslaget feiler.
}
}
try {
const mapped = await fetchPublishedArticleBySlug(slug, section);
if (mapped) {
return mapped;
}
} catch {
// Returnerer null dersom API-et ikke er tilgjengelig.
}
return null;
}
export async function getCourseVisits() {
return getEditorialArticles("banebesok");
}
export async function getCourseVisitBySlug(slug: string, options?: { preview?: boolean }) {
return getEditorialArticleBySlug(slug, "banebesok", options);
}
export async function getOpinionArticles() {
return getEditorialArticles("meninger");
}
export async function getOpinionArticleBySlug(slug: string, options?: { preview?: boolean }) {
return getEditorialArticleBySlug(slug, "meninger", options);
}
export async function getFacilityEditorialArticles(facilitySlug: string, limit = 3) {
const normalizedFacilitySlug = String(facilitySlug || "").trim();
if (!normalizedFacilitySlug) {
return {
banebesok: [] as EditorialArticle[],
meninger: [] as EditorialArticle[],
};
}
try {
const [banebesok, meninger] = await Promise.all([
fetchPublishedFacilityArticles("banebesok", normalizedFacilitySlug, limit),
fetchPublishedFacilityArticles("meninger", normalizedFacilitySlug, limit),
]);
return {
banebesok: banebesok || [],
meninger: meninger || [],
};
} catch {
return {
banebesok: [] as EditorialArticle[],
meninger: [] as EditorialArticle[],
};
}
}