859 lines
26 KiB
TypeScript
859 lines
26 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { cookies } from "next/headers";
|
|
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 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;
|
|
metaTitle?: string;
|
|
metaDescription?: 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;
|
|
meta_title?: string | null;
|
|
meta_description?: string | null;
|
|
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;
|
|
};
|
|
|
|
type ImportedOpinionRecord = {
|
|
slug?: string;
|
|
status?: string | null;
|
|
title?: string | null;
|
|
excerpt?: string | null;
|
|
contentHtml?: string | null;
|
|
publishedAt?: string | null;
|
|
updatedAt?: string | null;
|
|
link?: string | null;
|
|
featuredImage?: {
|
|
url?: string | null;
|
|
alt?: string | null;
|
|
caption?: string | null;
|
|
} | null;
|
|
author?: {
|
|
name?: string | null;
|
|
} | null;
|
|
};
|
|
|
|
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";
|
|
}
|
|
|
|
function normalizeImportedStatus(value?: string | null): "draft" | "published" {
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
return normalized === "published" || normalized === "publish" ? "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 `<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*(?: |\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,
|
|
metaTitle: String(entry.meta_title || "").trim() || undefined,
|
|
metaDescription: String(entry.meta_description || "").trim() || undefined,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function mapImportedOpinionArticle(entry: ImportedOpinionRecord): EditorialArticle | null {
|
|
const slug = String(entry.slug || "").trim();
|
|
const title = String(entry.title || "").trim();
|
|
if (!slug || !title) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedHtml = normalizeInternalLinks(String(entry.contentHtml || ""));
|
|
const preparedHtml = prepareRichTextHtml(normalizedHtml, title);
|
|
const extractedMedia = extractMediaFromHtml(normalizedHtml, title);
|
|
const featuredSrc = normalizeMediaUrl(String(entry.featuredImage?.url || "").trim());
|
|
const featuredAlt = String(entry.featuredImage?.alt || "").trim() || title;
|
|
const featuredCaption =
|
|
String(entry.featuredImage?.caption || "").trim() || featuredAlt || title;
|
|
|
|
const fallbackMedia = featuredSrc
|
|
? [
|
|
{
|
|
id: buildMediaId("image", featuredSrc),
|
|
type: "image" as const,
|
|
src: featuredSrc,
|
|
alt: featuredAlt,
|
|
caption: featuredCaption,
|
|
poster: "",
|
|
},
|
|
]
|
|
: [];
|
|
|
|
const mediaGallery = sanitizeMediaGallery([...fallbackMedia, ...extractedMedia], title).slice(0, 24);
|
|
const featuredMediaId = mediaGallery.find((item) => item.type === "image")?.id;
|
|
const heroImages = buildHeroImagesFromMedia(mediaGallery, title, featuredMediaId).slice(0, 6);
|
|
const excerpt =
|
|
String(entry.excerpt || "").trim() ||
|
|
stripHtml(normalizedHtml).slice(0, 220);
|
|
const publishedAt = String(entry.publishedAt || entry.updatedAt || "").trim();
|
|
|
|
return {
|
|
section: "meninger",
|
|
slug,
|
|
eyebrow: "Meninger",
|
|
title,
|
|
metaTitle: undefined,
|
|
metaDescription: undefined,
|
|
description: excerpt,
|
|
excerpt,
|
|
locationLabel: "Norge",
|
|
publishedAt,
|
|
updatedAt: String(entry.updatedAt || "").trim() || undefined,
|
|
readingTime: getReadingTime(preparedHtml),
|
|
heroImages:
|
|
heroImages.length > 0
|
|
? heroImages
|
|
: [
|
|
{
|
|
src: "/Toppbilde-standard.jpg",
|
|
alt: title,
|
|
caption: title,
|
|
},
|
|
],
|
|
mediaGallery,
|
|
featuredMediaId,
|
|
quickFacts: buildQuickFacts({
|
|
publishedAt,
|
|
authorName: String(entry.author?.name || "").trim() || undefined,
|
|
}),
|
|
highlights: buildHighlights("meninger"),
|
|
blocks: [
|
|
{
|
|
type: "richText",
|
|
html: preparedHtml,
|
|
},
|
|
],
|
|
sourceUrl: String(entry.link || "").trim() || undefined,
|
|
sourceLabel: "Legacy TeeOff",
|
|
status: normalizeImportedStatus(entry.status),
|
|
isPreview: false,
|
|
};
|
|
}
|
|
|
|
function getImportedOpinionArticles() {
|
|
return (Array.isArray(importedMeninger) ? importedMeninger : [])
|
|
.map((entry) => mapImportedOpinionArticle(entry as ImportedOpinionRecord))
|
|
.filter((entry): entry is EditorialArticle => Boolean(entry && entry.status === "published"))
|
|
.sort((a, b) => {
|
|
const aTs = Date.parse(a.publishedAt || a.updatedAt || "") || 0;
|
|
const bTs = Date.parse(b.publishedAt || b.updatedAt || "") || 0;
|
|
return bTs - aTs;
|
|
});
|
|
}
|
|
|
|
function getImportedOpinionArticleBySlug(slug: string) {
|
|
const normalizedSlug = String(slug || "").trim();
|
|
if (!normalizedSlug) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
getImportedOpinionArticles().find((entry) => entry.slug === normalizedSlug) || null
|
|
);
|
|
}
|
|
|
|
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.
|
|
}
|
|
|
|
if (section === "meninger") {
|
|
return getImportedOpinionArticles();
|
|
}
|
|
|
|
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.
|
|
}
|
|
|
|
if (section === "meninger") {
|
|
return getImportedOpinionArticleBySlug(slug);
|
|
}
|
|
|
|
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[],
|
|
};
|
|
}
|
|
}
|