import { API_URL } from "@/config/constants"; import importedMeninger from "@/content/importedMeninger.json"; export type ArticleSection = "banebesok" | "meninger"; export type CourseVisitImage = { src: string; alt: string; caption: 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[]; quickFacts: CourseVisitFact[]; highlights: string[]; blocks: CourseVisitBodyBlock[]; sourceUrl?: string; sourceLabel?: string; }; type ImportedCategory = { name?: string | null; slug?: string | null; }; type ImportedMeningerRecord = { id: number; slug: string; title: string; excerpt: string; contentHtml: string; publishedAt: string; updatedAt?: string; link?: string; author?: { name?: string | null; }; featuredImage?: { url?: string | null; alt?: string | null; caption?: string | null; } | null; categories?: ImportedCategory[]; categorySlugs?: string[]; facilitySlugs?: string[]; primaryFacilitySlug?: string | null; }; type ArticleApiRecord = { id?: number; section?: 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; 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 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"; } export function buildEditorialPath(section: ArticleSection, slug: string) { return `/${section}/${slug}`; } function getSectionLabel(section: ArticleSection) { return section === "meninger" ? "Meninger" : "Banebesøk"; } function resolveImportedSection(entry: ImportedMeningerRecord): { section: ArticleSection; eyebrow: string; } { const slugSet = new Set( (entry.categorySlugs || []) .map((slug) => String(slug || "").trim().toLowerCase()) .filter(Boolean), ); if (slugSet.has("banebesok")) { return { section: "banebesok", eyebrow: "Banebesøk" }; } if (slugSet.has("siste-nytt")) { return { section: "meninger", eyebrow: "Siste nytt" }; } const categoryLabel = (entry.categories || []) .map((category) => String(category?.name || "").trim()) .find(Boolean); return { section: "meninger", eyebrow: categoryLabel || "Meninger", }; } 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 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 extractImagesFromHtml(html: string, articleTitle: string) { const images: CourseVisitImage[] = []; const seen = new Set(); for (const match of html.matchAll(imageTagPattern)) { const src = 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); images.push({ src, alt: alt || articleTitle, caption: alt || articleTitle, }); } if (images.length === 0) { for (const match of html.matchAll(imageTagWithoutAltPattern)) { const src = match[1]; if (!src || seen.has(src) || !src.includes("/wp-content/uploads/")) { continue; } seen.add(src); images.push({ src, alt: articleTitle, caption: articleTitle, }); } } return images; } function buildQuickFacts(args: { facilityName?: string; facilitySlug?: string; publishedAt?: string; authorName?: string; sourceLabel?: 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: args.authorName || "TeeOff", }); if (args.sourceLabel) { facts.push({ label: "Kildespor", value: args.sourceLabel, }); } 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 mapImportedArticle(entry: ImportedMeningerRecord): EditorialArticle { const { section, eyebrow } = resolveImportedSection(entry); const facilitySlug = entry.primaryFacilitySlug || entry.facilitySlugs?.[0] || undefined; const facilityMeta = facilitySlug ? getFacilityMeta(facilitySlug) : null; const facilityName = facilityMeta?.name; const locationLabel = facilityMeta?.region || "Norge"; const normalizedHtml = normalizeInternalLinks(entry.contentHtml || ""); const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title); const featuredImage = entry.featuredImage?.url ? [ { src: entry.featuredImage.url, alt: entry.featuredImage.alt || entry.title, caption: entry.featuredImage.caption || entry.title, }, ] : []; const heroImages = [...featuredImage, ...extractedImages] .filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index) .slice(0, 6); const excerpt = entry.excerpt || stripHtml(normalizedHtml).slice(0, 220); return { section, slug: entry.slug, eyebrow, title: entry.title, description: excerpt, excerpt, locationLabel, facilityName, facilitySlug, publishedAt: entry.publishedAt, updatedAt: entry.updatedAt, readingTime: getReadingTime(normalizedHtml), heroImages: heroImages.length > 0 ? heroImages : [ { src: "/Toppbilde-standard.jpg", alt: entry.title, caption: entry.title, }, ], quickFacts: buildQuickFacts({ facilityName, facilitySlug, publishedAt: entry.publishedAt, authorName: entry.author?.name || "TeeOff", sourceLabel: "Importert fra gamle TeeOff", }), highlights: [ "Originalartikkel importert fra gamle TeeOff.", ...buildHighlights(section, facilityName), ], blocks: [ { type: "richText", title: "Original artikkel", html: normalizedHtml, }, ], sourceUrl: entry.link, sourceLabel: "Importert fra gamle TeeOff", }; } 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 extractedImages = extractImagesFromHtml(normalizedHtml, entry.title); const dbImages = Array.isArray(entry.hero_images) ? entry.hero_images : []; const heroImages = [...dbImages, ...extractedImages] .filter((image): image is CourseVisitImage => Boolean(image?.src)) .map((image) => ({ src: image.src, alt: image.alt || entry.title, caption: image.caption || image.alt || entry.title, })) .filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index) .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(normalizedHtml), heroImages: heroImages.length > 0 ? heroImages : [ { src: "/Toppbilde-standard.jpg", alt: entry.title, caption: entry.title, }, ], quickFacts: buildQuickFacts({ facilityName, facilitySlug, publishedAt, authorName: String(entry.author_name || "").trim() || "TeeOff", sourceLabel: String(entry.source_label || "").trim() || undefined, }), highlights: buildHighlights(section, facilityName), blocks: [ { type: "richText", title: "Artikkel", html: normalizedHtml, }, ], sourceUrl: String(entry.source_url || "").trim() || undefined, sourceLabel: String(entry.source_label || "").trim() || undefined, }; } const fallbackEditorialArticles = (importedMeninger as ImportedMeningerRecord[]) .map(mapImportedArticle) .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); function getFallbackArticles(section: ArticleSection) { return fallbackEditorialArticles.filter((article) => article.section === section); } 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 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); } export async function getEditorialArticles(section: ArticleSection) { try { const mapped = await fetchPublishedArticles(section); if (mapped && mapped.length > 0) { return mapped; } } catch { // Faller tilbake til importerte artikler dersom DB/API ikke er klar. } return getFallbackArticles(section); } export async function getEditorialArticleBySlug(slug: string, section: ArticleSection) { try { const mapped = await fetchPublishedArticleBySlug(slug, section); if (mapped) { return mapped; } } catch { // Faller tilbake til importerte artikler dersom DB/API ikke er klar. } return getFallbackArticles(section).find((article) => article.slug === slug); } export async function getCourseVisits() { return getEditorialArticles("banebesok"); } export async function getCourseVisitBySlug(slug: string) { return getEditorialArticleBySlug(slug, "banebesok"); } export async function getOpinionArticles() { return getEditorialArticles("meninger"); } export async function getOpinionArticleBySlug(slug: string) { return getEditorialArticleBySlug(slug, "meninger"); }