541 lines
15 KiB
TypeScript
541 lines
15 KiB
TypeScript
|
|
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<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 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<string>();
|
||
|
|
|
||
|
|
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");
|
||
|
|
}
|