Files
folx-tv/server/static.ts
T
2026-10-13 09:59:25 +02:00

801 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { publisherSchema, homeSchema, INFO_SEO, articleModifiedTime, articleGenre } from "../shared/seo";
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import { storage } from "./storage";
import sharp from "sharp";
// 15. 9. 2026 (Sebastjan): stran je v keywords vsakemu clanku pisala
// "Volksmusik, Schlager" — tudi Beckerju, Geissensom in Ivanovic. Cevovod ze ve,
// kaj kos je (znacka MUSIC NEWS / STAR NEWS), zato jo zapise v vsebino kot
// marker <!--folx-badge:STAR NEWS-->, enako kot <!--folx-updated:...-->.
// Brez markerja ostane vse kot prej (glasbeno), da starejsi clanki ne nazadujejo.
// Besedilne zaznave namenoma NI: preizkus na 30 clankih jo je zgresil pri
// Fernsehgartnu in Carpendaleju.
const folxGenre = (article: any) => articleGenre(String(article?.content || ""));
function escapeHtml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Dejanske dimenzije naslovnice. Prej sta bili sirina in visina v meta
// oznakah fiksno zapisani kot 1200x630, slike pa so v resnici 1200x900 —
// Facebook in Google sta dobila napacno vrednost. Ce dimenzij ne moremo
// prebrati, oznak raje ne zapisemo.
const ogDimCache = new Map<string, { w: number; h: number } | null>();
async function ogImageDims(imgUrl: string, publicDir: string) {
if (ogDimCache.has(imgUrl)) return ogDimCache.get(imgUrl) ?? null;
let dims: { w: number; h: number } | null = null;
try {
const m = /\/(uploads|images)\/([^/?#]+)$/.exec(imgUrl);
if (m) {
const cand = [
path.join(publicDir, m[1], m[2]),
path.join(publicDir, m[1], m[2].replace(/\.jpg$/, ".webp")),
];
for (const p of cand) {
if (!fs.existsSync(p)) continue;
const meta = await sharp(p).metadata();
if (meta.width && meta.height) { dims = { w: meta.width, h: meta.height }; break; }
}
}
} catch (e) {
dims = null;
}
ogDimCache.set(imgUrl, dims);
return dims;
}
function ogImageUrl(coverImage: string, baseUrl: string): string {
if (!coverImage) return "";
let imgPath = coverImage;
// Cloudinary: vsili FB razmerje 1200x630 z g_faces (da FB ne reze glav)
if (imgPath.includes("res.cloudinary.com")) {
imgPath = imgPath.replace(/([,&/?])h_\d+/i, "$1h_630").replace(/([,&/?])w_\d+/i, "$1w_1200");
// ce ni g_faces, ga dodaj
if (!/g_faces/.test(imgPath)) {
imgPath = imgPath.replace(/\/(image\/(upload|fetch))\//, "/$1/g_faces,");
}
return imgPath;
}
if (imgPath.endsWith(".webp")) {
imgPath = imgPath.replace(/\.webp$/, ".jpg");
}
return imgPath.startsWith("http") ? imgPath : `${baseUrl}${imgPath}`;
}
// Stari slugi clankov, ki so bili pozneje preimenovani. Brez tega vsak star
// link (FB objave, komentarji, go.folx.tv kratki linki, Googlov indeks) pade
// na trdi 404. Kljuc = star slug, vrednost = trenutni slug.
const ARTICLE_SLUG_REDIRECTS: Record<string, string> = {
"alexander-rier-verraet-das-ist-die-neue-frau-an-seiner-seite":
"alexander-rier-von-mann-zu-mann-immer-wieder-sonntags",
// 25. 8. 2026 izbrisan dvojnik: bot je isto zgodbo objavil dvakrat.
// Andreas Martin je bil vrnjen (bil je ze v Search Console), zato tu ni.
"isi-glueck-sagt-konzerte-ab-stimme-in-gefahr":
"isi-glueck-sagt-konzerte-ab-sorge-um-ihre-stimme",
};
// Rubrike, ki jih pisemo enkrat in objavimo tudi na onemusic.news. Besedilo je
// isto na obeh domenah, zato jih na folx.tv drzimo zunaj Googlovega indeksa —
// v bazi in v /api/articles ostanejo, ker jih bere sidebar na TV kanalih.
// Izdajatelj: doslej samo ime in logo. Brez registrirane druzbe, naslova in
// profilov Google znamke nima s cim povezati.
const PUBLISHER = publisherSchema;
const ONE_RUBRIKEN = new Set(["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen", "Aktuelles"]);
// 30. 8. 2026: rubrike so izbrisane iz baze folx.tv in zivijo samo se na
// folx.news. Stare povezave s Facebooka in Googla morajo se vedno delati,
// zato ob neznanem slugu vprasamo folx.news, ali ga pozna, in preusmerimo.
let NEWS_SLUGS: Set<string> = new Set();
let NEWS_SLUGS_AT = 0;
async function newsHasSlug(slug: string): Promise<boolean> {
if (Date.now() - NEWS_SLUGS_AT > 600000) {
try {
const r = await fetch("https://folx.news/api/articles", {
headers: { "user-agent": "folx.tv-redirect" },
signal: AbortSignal.timeout(10000),
});
const j: any = await r.json();
if (Array.isArray(j) && j.length) {
NEWS_SLUGS = new Set(j.map((a: any) => String(a.slug)));
NEWS_SLUGS_AT = Date.now();
}
} catch (e) {
NEWS_SLUGS_AT = Date.now() - 540000; // ob napaki poskusi cez minuto
}
}
return NEWS_SLUGS.has(slug);
}
function setRobots(html: string, content: string): string {
const tag = `<meta name="robots" content="${content}" />`;
if (/<meta\s+name="robots"[^>]*>/i.test(html)) {
return html.replace(/<meta\s+name="robots"[^>]*>/i, tag);
}
return html.replace("</head>", ` ${tag}\n </head>`);
}
function stripExistingMeta(html: string): string {
html = html.replace(/<meta\s+property="og:[^"]*"[^>]*>\s*/gi, "");
html = html.replace(/<meta\s+name="twitter:[^"]*"[^>]*>\s*/gi, "");
html = html.replace(/<meta\s+name="description"[^>]*>\s*/gi, "");
html = html.replace(/<meta\s+name="keywords"[^>]*>\s*/gi, "");
html = html.replace(/<link\s+rel="canonical"[^>]*>\s*/gi, "");
return html;
}
export function serveStatic(app: Express) {
const distPath = path.resolve(__dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
app.use(express.static(distPath, {
// brez tega express.static sam postrezhe index.html za "/" in nasa
// naslovnica nikoli ne pride do handlerja, ki ji doda strukturirane podatke
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith("og-image.jpg") || filePath.includes("/uploads/") || filePath.includes("/images/")) {
res.setHeader("Cache-Control", "public, max-age=86400");
}
},
}));
app.use("/{*path}", async (req, res) => {
const url = req.originalUrl;
const indexPath = path.resolve(distPath, "index.html");
const canonicalBase = "https://folx.tv";
const host = req.get("host") || "folx.tv";
const protocol = req.get("x-forwarded-proto") || "https";
const baseUrl = `${protocol}://${host}`;
// ---- /video/<slug>: napovednik je predvajljiv na strani, cel video na YouTube ----
const ytVideoMatch = url.match(/^\/video\/([^?#\/]+)/);
if (ytVideoMatch) {
try {
const slug = decodeURIComponent(ytVideoMatch[1]);
const idxFile = path.resolve(process.cwd(), "client/public/uploads/video/index.json");
const list: any[] = JSON.parse(await fs.promises.readFile(idxFile, "utf-8")).items || [];
const v = list.find((i) => i.slug === slug || i.ytId === slug);
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
if (!v) {
template = template.replace(/<title>[^<]*<\/title>/, [
'<meta name="robots" content="noindex, follow" />',
'<title>Video nicht gefunden | Folx Music Television</title>',
].join("\n "));
res.status(404).set({ "Content-Type": "text/html" }).end(template);
return;
}
const pageUrl = `${canonicalBase}/video/${v.slug}`;
const poster = `${canonicalBase}${v.posterUrl}`;
const desc = (v.teaser || v.shortTitle || v.title).slice(0, 300);
const longDesc = String(v.descriptionClean || v.description || v.teaser || v.shortTitle)
.replace(/https?:\/\/\S+/g, "").replace(/\s+/g, " ").trim().slice(0, 900);
const mins = Math.floor((v.durationS || 0) / 60), secs = (v.durationS || 0) % 60;
const embedUrl = v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`;
// Poglavja: iz "1:24" naredimo sekunde, da jih lahko damo v Clip
// oznacbo (Google iz nje naredi "Wichtige Momente" pod videom) in da
// je vsako poglavje klikljiva povezava z ?t=<sekunde>.
const chapSecs = (t: string) => {
const p = String(t).split(":").map((x) => parseInt(x, 10) || 0);
return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1];
};
const chaps = (v.chapters || [])
.map((c: any) => ({ ...c, s: chapSecs(c.t) }))
.filter((c: any) => c.s < (v.durationS || 0))
.sort((a: any, b: any) => a.s - b.s);
const startAt = parseInt(String((req.query as any)?.t || ""), 10);
const embedSrc = Number.isFinite(startAt) && startAt > 0
? `${embedUrl}?start=${startAt}` : embedUrl;
const videoLd = {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": v.shortTitle,
"description": longDesc || desc,
"thumbnailUrl": [poster],
"uploadDate": v.publishedAt,
"duration": `PT${mins}M${secs}S`,
"embedUrl": embedUrl,
"contentUrl": v.youtubeUrl,
"isFamilyFriendly": true,
"inLanguage": "de",
"publisher": PUBLISHER(canonicalBase),
"mainEntityOfPage": { "@type": "WebPage", "@id": pageUrl },
...(chaps.length >= 3 ? {
hasPart: chaps.map((c: any, i: number) => ({
"@type": "Clip",
"name": c.label,
"startOffset": c.s,
"endOffset": i + 1 < chaps.length ? chaps[i + 1].s : (v.durationS || c.s + 30),
"url": `${pageUrl}?t=${c.s}`,
})),
potentialAction: {
"@type": "SeekToAction",
"target": `${pageUrl}?t={seek_to_second_number}`,
"startOffset-input": "required name=seek_to_second_number",
},
} : {}),
};
const tags = [
`<meta property="og:title" content="${escapeHtml(v.shortTitle)}" />`,
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
`<meta property="og:type" content="video.other" />`,
`<meta property="og:url" content="${escapeHtml(pageUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(poster)}" />`,
`<meta property="og:image:width" content="1280" />`,
`<meta property="og:image:height" content="720" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(v.shortTitle)}" />`,
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
`<meta name="twitter:image" content="${escapeHtml(poster)}" />`,
`<meta name="description" content="${escapeHtml(desc)}" />`,
`<link rel="canonical" href="${escapeHtml(pageUrl)}" />`,
`<title>${escapeHtml(v.shortTitle)} | ${escapeHtml(v.rubrik)} | Folx Music Television</title>`,
`<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, tags);
const chapterHtml = chaps.length
? `<h2>Kapitel</h2><ul>${chaps.map((c: any) => `<li><a href="${escapeHtml(pageUrl)}?t=${c.s}">${escapeHtml(c.t)}</a> &ndash; ${escapeHtml(c.label)}</li>`).join("")}</ul>`
: "";
// Celoten opis gre v SSR, da ga Google prebere brez JavaScripta.
// Prej je bil v HTML samo naslov in teaser (~320 znakov), ostalo je
// izrisal sele React.
const descHtml = String(v.descriptionClean || v.description || "")
.split(/\n{2,}/)
.map((par: string) => par.trim())
.filter((par: string) => par.length > 2 && !/^https?:\/\//.test(par) && !/^[\u2013\u2014-]{4,}$/.test(par))
.map((par: string) => `<p>${escapeHtml(par).replace(/\n/g, "<br />")}</p>`)
.join("");
const ssr = [
'<article class="ssr-content">',
`<h1>${escapeHtml(v.shortTitle)}</h1>`,
`<iframe width="640" height="360" src="${escapeHtml(embedSrc)}" title="${escapeHtml(v.shortTitle)}" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="border:0;max-width:100%;"></iframe>`,
`<p><strong>${escapeHtml(desc)}</strong></p>`,
chapterHtml,
`<p><a href="${escapeHtml(v.youtubeUrl)}" target="_blank" rel="noopener">Das ganze Video auf YouTube ansehen</a></p>`,
v.articleUrl ? `<p><a href="${escapeHtml(v.articleUrl)}">Ganzen Artikel lesen</a></p>` : "",
descHtml ? `<h2>Zum Video</h2>${descHtml}` : "",
"</article>",
].join("\n");
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssr}</div>`);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
} catch (e) {
}
}
// ---- Naslovnica: doslej brez strukturiranih podatkov. Organization in
// WebSite sta tista dva vozlisca, na katera se veze vse ostalo. ----
if (url === "/" || url.startsWith("/?")) {
try {
let template = await fs.promises.readFile(indexPath, "utf-8");
const homeLd = homeSchema(canonicalBase);
template = template.replace(
"</head>",
`<script id="home-jsonld" type="application/ld+json">${JSON.stringify(homeLd).replace(/</g, "\\u003c")}</script>\n</head>`);
// 31. 8. 2026: naslovnica je React aplikacija, zato je robot dobil samo
// lupino brez ene same povezave na clanek. Clanki se do Googla pridejo
// prek sitemapa, a odkrivanje je pocasnejse. Zato v #root vpisemo se
// strezniski seznam zadnjih 40 clankov; React ga ob zagonu zamenja s
// pravo mrezo, robot pa povezave dobi takoj in brez izrisa JavaScripta.
try {
const vsi = await storage.getArticles();
const zadnji = [...vsi]
.filter((a: any) => !a.targetUrl && !ONE_RUBRIKEN.has(a.category || ""))
.sort((a: any, b: any) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
.slice(0, 40);
if (zadnji.length) {
const seznam = zadnji
.filter((a: any) => a.slug && a.title)
.map((a: any) =>
`<li><a href="/article/${escapeHtml(a.slug)}">${escapeHtml(a.title)}</a></li>`)
.join("");
const blok = `<div id="ssr-neueste"><h2>Neueste Artikel</h2><ul>${seznam}</ul>` +
`<p><a href="/category/News">Alle Nachrichten</a></p></div>`;
template = template.replace('<div id="root"></div>', () => `<div id="root">${blok}</div>`);
}
} catch (e) {
}
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
} catch (e) {
}
}
// 29. 8. 2026: rubrike Aktuelles so preseljene na loceno stran folx.news.
// Clanki ostanejo v bazi in v /api/articles (bere jih folx.news in TV sidebar),
// na folx.tv pa se naslovi trajno preusmerijo.
const NEWS_HOST = "https://folx.news";
const NEWS_RUBRIK_SLUG: Record<string, string | undefined> = {
"Politik": "politik",
"Promis": "promis",
"Sport": "sport",
"Musik & Film": "musik-film",
"Wetter": "wetter",
"Finanzen": "finanzen",
"Aktuelles": "",
};
const pathOnly = url.split("?")[0].split("#")[0];
// 30. 8. 2026: horoskop, recepti in galerija so ukinjeni. Stare povezave
// ne smejo v 404, zato gredo trajno na naslovnico.
if (/^\/(horoskop|rezepte|gallery|galerie)(\/.*)?$/.test(pathOnly)) {
res.redirect(301, "/");
return;
}
if (/^\/aktuelles\/?$/.test(pathOnly)) {
res.redirect(301, `${NEWS_HOST}/`);
return;
}
const catRedirect = pathOnly.match(/^\/category\/([^/]+)\/?$/);
if (catRedirect) {
const catName = decodeURIComponent(catRedirect[1]);
const key = Array.from(ONE_RUBRIKEN).find((r) => r.toLowerCase() === catName.toLowerCase());
if (key) {
const rs = NEWS_RUBRIK_SLUG[key];
res.redirect(301, rs ? `${NEWS_HOST}/rubrik/${rs}` : `${NEWS_HOST}/`);
return;
}
}
// ---- /category/<ime>: prej je vrnil HTML naslovnice s canonical "/" in
// brez vsebine, kar je Google bral kot soft 404 / podvojeno naslovnico ----
const catMatch = url.match(/^\/category\/([^?#\/]+)/);
if (catMatch) {
try {
const cat = decodeURIComponent(catMatch[1]);
const all = await storage.getArticles();
const list = all
.filter((a: any) => (a.category || "").toLowerCase() === cat.toLowerCase() && !a.targetUrl)
.slice(0, 40);
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
if (!list.length) {
template = template.replace(/<title>[^<]*<\/title>/, [
'<meta name="robots" content="noindex, follow" />',
`<title>${escapeHtml(cat)} | Folx Music Television</title>`,
].join("\n "));
res.status(404).set({ "Content-Type": "text/html" }).end(template);
return;
}
const catUrl = `${canonicalBase}/category/${encodeURIComponent(cat)}`;
const isNews = cat.toLowerCase() === "news";
const catHeading = isNews ? "Schlager & Volksmusik News" : cat;
const catTitle = isNews
? `${catHeading} | FOLX TV`
: `${cat} – Volksmusik & Schlager | Folx Music Television`;
const catDesc = isNews
? "Aktuelle Schlager & Volksmusik News: Nachrichten, Interviews und Neuigkeiten aus der Musikszene bei FOLX TV."
: `Alle Beiträge aus der Rubrik ${cat} bei FOLX TV: aktuelle Meldungen, Interviews und Videos aus Volksmusik und Schlager.`;
const firstImg = ogImageUrl(list[0]?.coverImage || "", canonicalBase) || `${canonicalBase}/og-image.jpg`;
const itemLd = {
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": catTitle,
"description": catDesc,
"url": catUrl,
"mainEntity": {
"@type": "ItemList",
"itemListElement": list.slice(0, 20).map((a: any, i: number) => ({
"@type": "ListItem",
"position": i + 1,
"url": `${canonicalBase}/article/${a.slug}`,
"name": a.title,
})),
},
};
const tags = [
`<meta property="og:title" content="${escapeHtml(catTitle)}" />`,
`<meta property="og:description" content="${escapeHtml(catDesc)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeHtml(catUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(firstImg)}" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(catTitle)}" />`,
`<meta name="twitter:image" content="${escapeHtml(firstImg)}" />`,
`<meta name="description" content="${escapeHtml(catDesc)}" />`,
`<link rel="canonical" href="${escapeHtml(catUrl)}" />`,
`<title>${escapeHtml(catTitle)}</title>`,
`<script type="application/ld+json">${JSON.stringify(itemLd).replace(/</g, "\\u003c")}</script>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, tags);
// Rubrike, ki sestavljajo /aktuelles, niso del indeksiranega dela strani:
// clanki so ze noindex, zato tudi njihov seznam ne sme v indeks.
if (Array.from(ONE_RUBRIKEN).some((r) => r.toLowerCase() === cat.toLowerCase())) {
template = setRobots(template, "noindex, follow");
}
const ssr = [
'<section class="ssr-content">',
`<h1>${escapeHtml(cat)}</h1>`,
"<ul>",
...list.map((a: any) => {
const img = ogImageUrl(a.coverImage || "", canonicalBase);
return `<li><a href="/article/${escapeHtml(a.slug)}">` +
(img ? `<img src="${escapeHtml(img)}" alt="${escapeHtml(a.title)}" width="320" style="max-width:100%;height:auto;" /> ` : "") +
`<strong>${escapeHtml(a.title)}</strong></a><p>${escapeHtml(a.excerpt || "")}</p></li>`;
}),
"</ul>",
"</section>",
].join("\n");
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssr}</div>`);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
} catch (e) {
}
}
const articleMatch = url.match(/^\/article\/([^?#]+)/);
if (articleMatch) {
try {
const slug = decodeURIComponent(articleMatch[1]);
const article = await storage.getArticleBySlug(slug);
if (article && ONE_RUBRIKEN.has((article as any).category || "")) {
res.redirect(301, `${NEWS_HOST}/${article.slug}`);
return;
}
if (!article && (await newsHasSlug(slug))) {
res.redirect(301, `${NEWS_HOST}/${slug}`);
return;
}
if (article && (article as any).targetUrl) {
res.redirect(302, (article as any).targetUrl);
return;
}
if (article) {
const articleUrl = `${canonicalBase}/article/${article.slug}`;
const imageUrl = ogImageUrl(article.coverImage || "", canonicalBase);
const finalImage = imageUrl || `${canonicalBase}/og-image.jpg`;
const articleImgDims = await ogImageDims(finalImage, distPath);
if (!imageUrl) {
console.warn(`[og] clanek brez naslovnice, pada na logotip: ${article.slug}`);
}
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const pubDate = article.publishedAt
? new Date(article.publishedAt as any).toISOString()
: new Date().toISOString();
// dateModified: ce je clanek dopolnjen (enrich), preberi marker <!--folx-updated:ISO-->
const modDate = articleModifiedTime(article.content || "", pubDate);
// VideoObject: ce je v clanku vgrajen YouTube video
const videoId = (() => {
const mm = /youtube(?:-nocookie)?\.com\/embed\/([A-Za-z0-9_-]{6,})/.exec(article.content || "");
return mm ? mm[1] : null;
})();
const jsonLd = {
"@context": "https://schema.org",
"@type": "NewsArticle",
"@id": `${articleUrl}#article`,
"inLanguage": "de-DE",
"headline": article.title,
"description": article.excerpt,
"image": [finalImage],
"datePublished": pubDate,
"dateModified": modDate,
"articleSection": folxGenre(article).section,
"keywords": folxGenre(article).words.join(", "),
"author": {
"@type": "Organization",
"name": "FOLX Music Television",
"url": canonicalBase,
},
"publisher": PUBLISHER(canonicalBase),
"mainEntityOfPage": {
"@type": "WebPage",
"@id": articleUrl,
},
};
const jsonLdTag = `<script id="article-jsonld" type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>`;
const videoLd = videoId ? {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": article.title,
"description": article.excerpt,
"thumbnailUrl": [`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`],
"uploadDate": pubDate,
"embedUrl": `https://www.youtube.com/embed/${videoId}`,
"contentUrl": `https://www.youtube.com/watch?v=${videoId}`,
"publisher": PUBLISHER(canonicalBase),
} : null;
const videoLdTag = videoLd
? `<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`
: "";
const ogTags = [
`<meta property="og:title" content="${escapeHtml(article.title)}" />`,
`<meta property="og:description" content="${escapeHtml(article.excerpt)}" />`,
`<meta property="og:type" content="article" />`,
`<meta property="article:published_time" content="${pubDate}" />`,
`<meta property="article:modified_time" content="${modDate}" />`,
`<meta property="og:url" content="${escapeHtml(articleUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(finalImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(finalImage)}" />`,
...(articleImgDims
? [`<meta property="og:image:width" content="${articleImgDims.w}" />`,
`<meta property="og:image:height" content="${articleImgDims.h}" />`]
: []),
`<meta property="og:image:type" content="image/jpeg" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta property="fb:pages" content="281200958640258" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
`<meta name="twitter:image" content="${escapeHtml(finalImage)}" />`,
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
`<meta name="keywords" content="${escapeHtml(folxGenre(article).words.join(", "))}, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
`<title>${escapeHtml(article.title)} | FOLX TV</title>`,
jsonLdTag,
videoLdTag,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, ogTags);
// SSR: vgradi vsebino clanka v HTML, da jo Google/Discover vidita brez JS
const ssrBlock = [
'<article class="ssr-content">',
`<h1>${escapeHtml(article.title)}</h1>`,
imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(article.title)}" style="max-width:100%;height:auto;" />` : "",
`<p><strong>${escapeHtml(article.excerpt)}</strong></p>`,
article.content || "",
"</article>",
].join("\n");
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssrBlock}</div>`);
if (ONE_RUBRIKEN.has(article.category || "")) {
template = setRobots(template, "noindex, follow, max-image-preview:large");
}
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
// Star slug preimenovanega clanka -> trajna preusmeritev na novega.
const renamedTo = ARTICLE_SLUG_REDIRECTS[slug];
if (renamedTo) {
res.redirect(301, `/article/${renamedTo}`);
return;
}
// Clanka s tem slugom ni. Brez tega bi SPA vrnil HTTP 200 z naslovnico
// in canonical na "/" — Google to bere kot podvojeno naslovnico (soft 404),
// izbrisani clanki pa ostanejo v indeksu in v Discovru.
{
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
template = template.replace(
/<title>[^<]*<\/title>/,
[
'<meta name="robots" content="noindex, follow" />',
'<meta name="description" content="Dieser Artikel ist nicht mehr verfuegbar." />',
'<title>Artikel nicht gefunden | Folx Music Television</title>',
].join("\n "),
);
res.status(404).set({ "Content-Type": "text/html" }).end(template);
return;
}
} catch (e) {
}
}
// /rezepte in /videos sta prej vracala canonical na naslovnico, zato ju Google
// ni mogel indeksirati kot samostojni strani (29. 8. 2026).
const SEKCIJE: Record<string, { pot: string; naslov: string; opis: string } | undefined> = {
"/rezepte": {
pot: "/rezepte",
naslov: "Rezepte \u2013 Rezept des Tages | Folx Music Television",
opis: "Jeden Tag ein neues Rezept: einfache Gerichte, Klassiker und Ideen f\u00fcr jeden Anlass \u2013 bei FOLX TV.",
},
"/videos": {
pot: "/videos",
naslov: "Videos \u2013 Schlager und Volksmusik | Folx Music Television",
opis: "Alle Videos von FOLX TV: Interviews, Auftritte und Musik News aus Schlager und Volksmusik.",
},
};
const sekPot = url.split("?")[0].split("#")[0].replace(/\/$/, "");
const sek = SEKCIJE[sekPot];
if (sek) {
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const sUrl = `${canonicalBase}${sek.pot}`;
const sImg = `${canonicalBase}/images/og-image.jpg`;
const sTags = [
`<meta property="og:title" content="${escapeHtml(sek.naslov)}" />`,
`<meta property="og:description" content="${escapeHtml(sek.opis)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeHtml(sUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(sImg)}" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(sek.naslov)}" />`,
`<meta name="twitter:description" content="${escapeHtml(sek.opis)}" />`,
`<meta name="twitter:image" content="${escapeHtml(sImg)}" />`,
`<meta name="description" content="${escapeHtml(sek.opis)}" />`,
`<link rel="canonical" href="${escapeHtml(sUrl)}" />`,
`<title>${escapeHtml(sek.naslov)}</title>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, sTags);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
if (url.match(/^\/gallery(\/|$|\?)/)) {
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const galTitle = "Fotogalerie \u2013 Einzigartige Momente von unseren Aufzeichnungen | FOLX TV";
const galDesc = "Spannende Schnappsch\u00fcsse und besondere Augenblicke, eingefangen bei unseren Aufzeichnungen. Authentische Bilder der Volksmusik- und Schlager-Stars in einzigartigen Momenten bei FOLX TV.";
const galUrl = `${canonicalBase}/gallery`;
const galImage = `${canonicalBase}/images/og-image.jpg`;
const galTags = [
`<meta property="og:title" content="${escapeHtml(galTitle)}" />`,
`<meta property="og:description" content="${escapeHtml(galDesc)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeHtml(galUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(galImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(galImage)}" />`,
`<meta property="og:image:width" content="1200" />`,
`<meta property="og:image:height" content="630" />`,
`<meta property="og:image:type" content="image/jpeg" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(galTitle)}" />`,
`<meta name="twitter:description" content="${escapeHtml(galDesc)}" />`,
`<meta name="twitter:image" content="${escapeHtml(galImage)}" />`,
`<meta name="description" content="${escapeHtml(galDesc)}" />`,
`<meta name="keywords" content="Fotogalerie, Volksmusik Fotos, Schlager Bilder, Aufzeichnungen, Schnappschüsse, FOLX TV, Volksmusik Stars" />`,
`<link rel="canonical" href="${escapeHtml(galUrl)}" />`,
`<title>${escapeHtml(galTitle)}</title>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, galTags);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
if (url.match(/^\/horoskop(\/|$|\?)/)) {
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const signMatch = url.match(/^\/horoskop\/([^?#]+)/);
const signName = signMatch ? decodeURIComponent(signMatch[1]) : null;
const capitalSign = signName ? signName.charAt(0).toUpperCase() + signName.slice(1) : null;
const title = capitalSign
? `${capitalSign} Horoskop – Tages-, Wochen- & Monatshoroskop | Folx Music Television`
: "Horoskop – Tages-, Wochen- & Monatshoroskop für alle Sternzeichen | Folx Music Television";
const desc = capitalSign
? `${capitalSign} Horoskop: Tägliches, wöchentliches und monatliches Horoskop. Liebe, Beruf & Gesundheit bei FOLX TV.`
: "Kostenloses Tageshoroskop, Wochenhoroskop & Monatshoroskop für alle 12 Sternzeichen. Liebe, Beruf, Gesundheit bei FOLX TV.";
const horoUrl = `${canonicalBase}${signName ? `/horoskop/${signName}` : "/horoskop"}`;
const horoImage = `${canonicalBase}/images/horoskop-og.jpg`;
const horoTags = [
`<meta property="og:title" content="${escapeHtml(title)}" />`,
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeHtml(horoUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(horoImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(horoImage)}" />`,
`<meta property="og:image:width" content="1200" />`,
`<meta property="og:image:height" content="675" />`,
`<meta property="og:image:type" content="image/jpeg" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
`<meta name="twitter:image" content="${escapeHtml(horoImage)}" />`,
`<meta name="description" content="${escapeHtml(desc)}" />`,
`<meta name="keywords" content="Horoskop, Tageshoroskop, Wochenhoroskop, Monatshoroskop, Sternzeichen, Volksmusik, FOLX TV${capitalSign ? `, ${capitalSign}` : ""}" />`,
`<link rel="canonical" href="${escapeHtml(horoUrl)}" />`,
`<title>${escapeHtml(title)}</title>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, horoTags);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
// ---- /aktuelles: zbirna stran rubrik, ki so vse noindex. Prej je vracala
// HTML naslovnice s canonical "/", kar je Google bral kot podvojeno
// naslovnico. Zdaj ima svoj canonical in noindex, follow. ----
if (/^\/aktuelles\/?$/.test(url.split("?")[0].split("#")[0])) {
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const aktTitle = "Aktuelles | Folx Music Television";
template = template.replace(/<title>[^<]*<\/title>/, [
`<link rel="canonical" href="${canonicalBase}/aktuelles" />`,
`<title>${escapeHtml(aktTitle)}</title>`,
].join("\n "));
template = setRobots(template, "noindex, follow");
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
const infoPath = pathOnly.replace(/\/+$/, "");
const info = INFO_SEO[infoPath];
if (info) {
let template = stripExistingMeta(await fs.promises.readFile(indexPath, "utf-8"));
const pageUrl = `${canonicalBase}${infoPath}`;
const title = `${info.title} | FOLX TV`;
const ld = { "@context": "https://schema.org", "@type": info.type,
"@id": `${pageUrl}#webpage`, url: pageUrl, name: title, description: info.description,
inLanguage: "de-DE", isPartOf: { "@id": `${canonicalBase}/#website` }, publisher: PUBLISHER(canonicalBase) };
template = template.replace(/<title>[^<]*<\/title>/, [
`<title>${escapeHtml(title)}</title>`,
`<meta name="description" content="${escapeHtml(info.description)}" />`,
`<link rel="canonical" href="${pageUrl}" />`,
`<meta property="og:title" content="${escapeHtml(title)}" />`,
`<meta property="og:description" content="${escapeHtml(info.description)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${pageUrl}" />`,
`<meta property="og:image" content="${canonicalBase}/og-image.jpg" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
`<meta name="twitter:description" content="${escapeHtml(info.description)}" />`,
`<meta name="twitter:image" content="${canonicalBase}/og-image.jpg" />`,
`<script id="info-jsonld" type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`,
].join("\n"));
// 13. 10. 2026: Impressum (izdajatelj + odgovorni urednik) tudi v SSR, da ga Google vidi brez JS (E-E-A-T)
if (infoPath === "/impressum") {
const imp = `<main style="max-width:760px;margin:0 auto;padding:24px"><h1>Impressum</h1>`
+ `<h2>Herausgeber</h2><p>PRIME TIME Consulting GmbH<br/>Sigmaringer Str. 10<br/>72379 Hechingen<br/>Deutschland<br/>Geschäftsführerin: Heike Artič<br/>E-Mail: office@primetime.consulting</p>`
+ `<h2>Redaktion</h2><p>Chefredakteur: Sebastjan Artič<br/>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV: Sebastjan Artič, Sigmaringer Str. 10, 72379 Hechingen<br/>E-Mail: office@folx.tv</p>`
+ `<h2>Offenlegung nach Mediengesetz</h2><p>Medieninhaber: BoldFrame Productions d.o.o.<br/>Sokolska ulica 46, 2000 Maribor, Slowenien</p></main>`;
template = template.replace('<div id="root"></div>', () => `<div id="root">${imp}</div>`);
}
res.status(200).type("html").send(template);
return;
}
// Neznana pot mora vrniti PRAVI 404, sicer Google dobi soft 404 (HTTP 200
// z naslovnico). Seznam mora ustrezati potem v client/src/App.tsx.
const KNOWN = [
/^\/$/, /^\/search\/?$/, /^\/article\//, /^\/category\//, /^\/aktuelles\/?$/,
/^\/videos\/?$/, /^\/video\/[^/]+\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/,
/^\/empfang-folx-tv\/?$/, /^\/ueber-uns\/?$/, /^\/impressum\/?$/,
/^\/datenschutz\/?$/, /^\/kontakt\/?$/, /^\/admin(\/|$)/,
];
const cleanPath = url.split("?")[0].split("#")[0];
const known = KNOWN.some((re) => re.test(cleanPath));
let template = await fs.promises.readFile(indexPath, "utf-8");
if (!known) {
template = stripExistingMeta(template);
template = template.replace(
/<title>[^<]*<\/title>/,
[
'<title>Seite nicht gefunden | FOLX TV</title>',
'<meta name="robots" content="noindex, follow" />',
].join("\n ")
);
res.status(404).set({ "Content-Type": "text/html" }).end(template);
return;
}
res.status(200).set({ "Content-Type": "text/html" }).end(template);
});
}