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 = { "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 = /]*\bsrc=['"]([^'"]+)['"][^>]*\balt=['"]([^'"]*)['"][^>]*>/gi; const imageTagWithoutAltPattern = /]*\bsrc=['"]([^'"]+)['"][^>]*>/gi; const paragraphWithImagePattern = /]*>(?:(?!/gi; const anchorWrappedImagePattern = /]*>\s*]*>\s*<\/a>/gi; const standaloneImagePattern = /]*>\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(/…/g, "...") .replace(/…/g, "...") .replace(/ /g, " ") .replace(/«/g, "«") .replace(/»/g, "»") .replace(/&/g, "&") .replace(/&/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, "&").replace(/"/g, """); } 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 `
`; } function replaceYoutubeThumbnailImages(html: string, articleTitle: string) { return html.replace(/]*\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(/(?:

\s*(?: |\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(/]*>\s*<\/a>/gi, " ") .replace(//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(); 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(); 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[], }; } }