File: //tmp/bewertemich-generate-hubs.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import { audienceHubs, featuredSeoLinks, seoHub, seoLandingCatalog } from '../src/modules/seo/catalog.js';
const publicDir = path.resolve('/home/bewertemich.de/web/public');
const manifestPath = path.join(publicDir, 'landing-media', 'seed-manifest.json');
const seoManifestPath = path.join(publicDir, 'seo-manifest.json');
const siteBase = 'https://bewertemich.de';
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\"/g, '"');
const escapeAttr = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\"/g, '"')
.replace(/'/g, ''');
const seoTrackAttributes = (eventName, target = '', position = '') =>
`data-seo-track-event="${escapeAttr(eventName)}" data-seo-track-target="${escapeAttr(target)}" data-seo-track-position="${escapeAttr(position)}"`;
const slugifyEntry = (slug) => slug.replace(/-/g, '_');
const renderFaqSchema = (page) => ({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: (page.faqs || []).map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: {
'@type': 'Answer',
text: item.a
}
}))
});
const renderPageSchema = (page, canonical) => ({
'@context': 'https://schema.org',
'@type': 'Article',
headline: page.heading,
description: page.metaDescription,
inLanguage: 'de-DE',
about: page.tags || [],
mainEntityOfPage: canonical,
isAccessibleForFree: true,
image: page.heroImage ? siteBase + page.heroImage : undefined,
author: {
'@type': 'Organization',
name: 'bewertemich.de',
url: siteBase
},
publisher: {
'@type': 'Organization',
name: 'bewertemich.de',
url: siteBase
}
});
const readManifest = async () => {
const raw = await fs.readFile(manifestPath, 'utf8');
return JSON.parse(raw);
};
const buildMaps = (manifest) => ({
peopleBySlug: new Map((manifest.people || []).map((item) => [item.slug, item])),
comparisonBySlug: new Map((manifest.comparisons || []).map((item) => [item.slug, item]))
});
const buildRelatedMap = () => {
const map = new Map();
for (const item of seoLandingCatalog) map.set(item.slug, item);
for (const item of audienceHubs) map.set(item.slug, item);
map.set(seoHub.slug, seoHub);
return map;
};
const renderSampleCards = (page, peopleBySlug) => {
const people = (page.sampleSlugs || [])
.map((slug) => peopleBySlug.get(slug))
.filter(Boolean)
.slice(0, 3);
return people.map((person) => `
<article class="card photo-card">
<a href="${person.url}">
<img class="card-media" src="${person.imageUrl}" alt="${escapeHtml(person.title || person.displayName)}" width="480" height="600" loading="lazy" decoding="async" />
</a>
<div class="photo-card-body">
<span class="eyebrow">${escapeHtml(person.displayName)} · ${escapeHtml(person.city)}</span>
<h3>${escapeHtml(person.title || person.displayName)}</h3>
<p>Fiktives Seed-Foto aus Deutschland, geeignet für realistische iPhone-Style-Tests.</p>
<div class="metric-row">
<span class="metric">Foto #${person.photoId}</span>
<span class="metric">${person.variantPhotoId ? 'A/B möglich' : 'Einzelbild'}</span>
</div>
</div>
</article>
`).join('');
};
const renderRelatedLinks = (page, relatedMap) => {
const items = (page.related || [])
.map((slug) => relatedMap.get(slug))
.filter(Boolean)
.slice(0, 3);
return items.map((item) => `
<a class="related-link" href="/${item.slug}/" ${seoTrackAttributes('seo_related_click', item.slug, 'related-links')}>
<strong>${escapeHtml(item.title || item.heading)}</strong>
<span>${escapeHtml(item.subtitle || item.intro || '')}</span>
</a>
`).join('');
};
const trackingPixel = (slug, surface) => `
<script>
(function () {
const pageSlug = ${JSON.stringify(slug)};
const surface = ${JSON.stringify(surface)};
const toJson = (value) => {
try {
return JSON.stringify(value);
} catch {
return '{}';
}
};
const postEvent = (eventName, meta) => {
const payload = {
event: eventName,
source: 'seo_static',
surface,
entryPoint: pageSlug,
path: window.location.pathname,
referrer: window.document.referrer || '',
meta: meta || {}
};
const body = toJson(payload);
const endpoint = '/api/public/events';
if (navigator && typeof navigator.sendBeacon === 'function') {
const blob = typeof Blob === 'function' ? new Blob([body], { type: 'application/json;charset=utf-8' }) : null;
if (blob && navigator.sendBeacon(endpoint, blob)) return;
}
fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
credentials: 'same-origin'
}).catch(() => {});
};
postEvent('seo_landing_view', { position: 'page-load' });
for (const element of document.querySelectorAll('[data-seo-track-event]')) {
element.addEventListener('click', () => {
const eventName = element.getAttribute('data-seo-track-event') || 'seo_upload_click';
postEvent(eventName, {
target: element.getAttribute('data-seo-track-target') || '',
position: element.getAttribute('data-seo-track-position') || ''
});
});
}
})();
</script>`;
const renderPage = (page, maps, relatedMap) => {
const canonical = `${siteBase}/${page.slug}/`;
const comparison = maps.comparisonBySlug.get(page.compareSlug);
const comparisonImage = comparison ? `${siteBase}/s/compare/${comparison.id}/og.jpg` : `${siteBase}${page.heroImage}`;
const comparisonUrl = comparison ? `/compare/${comparison.id}` : `/upload?entry=seo_static_${slugifyEntry(page.slug)}`;
const comparisonLabel = comparison ? (comparison.slug || 'compare') : 'upload';
const sampleCards = renderSampleCards(page, maps.peopleBySlug);
const relatedLinks = renderRelatedLinks(page, relatedMap);
const schema = [renderPageSchema(page, canonical), renderFaqSchema(page)];
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${escapeHtml(page.title)} | bewertemich.de</title>
<meta name="description" content="${escapeHtml(page.metaDescription)}" />
<link rel="canonical" href="${canonical}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="${escapeHtml(page.title)}" />
<meta property="og:description" content="${escapeHtml(page.metaDescription)}" />
<meta property="og:url" content="${canonical}" />
<meta property="og:image" content="${siteBase}${page.heroImage}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeHtml(page.title)}" />
<meta name="twitter:description" content="${escapeHtml(page.metaDescription)}" />
<meta name="twitter:image" content="${siteBase}${page.heroImage}" />
<link rel="stylesheet" href="/landing.css" />
<script type="application/ld+json">${JSON.stringify(schema)}</script>
${trackingPixel(page.slug, 'seo_landing')}
</head>
<body>
<main class="shell">
<section class="hero hero-split">
<div class="hero-copy">
<div class="pill-row">
<span class="pill">${escapeHtml(page.tags?.[0] || 'Profilbild')}</span>
<span class="pill">Invite first</span>
<span class="pill">DE Longtail</span>
</div>
<h1>${escapeHtml(page.heading)}</h1>
<p>${escapeHtml(page.subtitle)}</p>
<p>${escapeHtml(page.intro)}</p>
<div class="actions">
<a class="btn btn-primary" href="/upload?entry=seo_static_${slugifyEntry(page.slug)}" ${seoTrackAttributes('seo_upload_click', page.slug, 'hero')}>${escapeHtml(page.ctaLabel)}</a>
<a class="btn btn-secondary" href="/top10">Öffentliche Rankings ansehen</a>
</div>
</div>
<div class="hero-visual">
<img class="hero-art" src="${page.heroImage}" alt="${escapeHtml(page.heroAlt)}" width="960" height="720" fetchpriority="high" decoding="async" />
<div class="hero-note">
<strong>${escapeHtml(page.proofTitle)}</strong>
<p>${escapeHtml(page.proofText)}</p>
</div>
</div>
</section>
<section class="proof-strip">
<article class="proof-card">
<strong>Belastbare Rankings</strong>
<p>Öffentliche Toplisten starten erst ab 5 Stimmen und 4 unabhängigen Votern.</p>
</article>
<article class="proof-card">
<strong>Invite first</strong>
<p>WhatsApp und Telegram liefern meist die schnellsten ersten Stimmen für ein klares Signal.</p>
</article>
<article class="proof-card">
<strong>Vergleich statt Chaos</strong>
<p>Wenn zwei Bilder eng sind, entscheidet der A/B-Modus schneller als jede Textdiskussion.</p>
</article>
</section>
<section class="grid cols-3">
${page.bullets.map((item) => `
<article class="card">
<span class="eyebrow">${escapeHtml(item.kicker)}</span>
<h2>${escapeHtml(item.title)}</h2>
<p>${escapeHtml(item.text)}</p>
</article>
`).join('')}
</section>
<section class="section-header">
<h2>Beispielbilder für ${escapeHtml(page.title)}</h2>
<p>Die Beispielbilder stammen aus dem Seed für bewertemich.de und zeigen realistische Smartphone-Aufnahmen statt austauschbarer Stockfotos.</p>
</section>
<section class="grid cols-3">
${sampleCards}
</section>
<section class="media-panel">
<img src="${comparisonImage}" alt="Share- oder Vergleichskarte für ${escapeHtml(page.title)}" width="960" height="720" loading="lazy" decoding="async" />
<div class="card">
<span class="eyebrow">Vergleichsmodus</span>
<h2>Wenn zwei Bilder nahe beieinander liegen, gewinnt der direkte Test</h2>
<p>Genau hier kippt die Entscheidung oft. Statt beide Fotos irgendwie okay zu finden, lässt du sie gegeneinander antreten und schaust auf den klaren Sieger.</p>
<ol class="list">
${page.steps.map((item) => `<li><strong>${escapeHtml(item.title)}:</strong> ${escapeHtml(item.text)}</li>`).join('')}
</ol>
<div class="actions">
<a class="btn btn-primary" href="${comparisonUrl}" ${seoTrackAttributes('seo_related_click', comparisonLabel, 'media-comparison')}>Beispiel ansehen</a>
<a class="btn btn-secondary dark" href="/upload?entry=seo_static_${slugifyEntry(page.slug)}" ${seoTrackAttributes('seo_upload_click', page.slug, 'media-cta')}>Eigenes Foto testen</a>
</div>
</div>
</section>
<section class="card">
<div class="section-header compact">
<h2>FAQ zu ${escapeHtml(page.title)}</h2>
</div>
<div class="faq-list">
${(page.faqs || []).map((item) => `
<article class="faq-item">
<h3>${escapeHtml(item.q)}</h3>
<p>${escapeHtml(item.a)}</p>
</article>
`).join('')}
</div>
</section>
<section class="card">
<div class="section-header compact">
<h2>Verwandte Themen</h2>
<p>Interne DE-Longtails für weitere Suchintentionen rund um Profilbild, Foto-Test und Vergleich.</p>
</div>
<div class="related-grid">
${relatedLinks}
</div>
</section>
<section class="cta">
<h2>${escapeHtml(page.heading)} direkt ausprobieren</h2>
<p>Vom Suchintent direkt in den Flow: hochladen, Invite-Link teilen, Siegerbild übernehmen.</p>
<div class="footer-actions">
<a class="btn btn-primary" href="/upload?entry=seo_static_${slugifyEntry(page.slug)}" ${seoTrackAttributes('seo_upload_click', page.slug, 'footer')}>${escapeHtml(page.ctaLabel)}</a>
<a class="btn btn-secondary" href="/profilbild-ratgeber/">Zum Ratgeber-Hub</a>
</div>
</section>
<div class="footer">bewertemich.de · ${escapeHtml(page.title)} · Deutschland</div>
</main>
</body>
</html>`;
};
const renderAudienceHub = (hub, relatedMap) => {
const canonical = `${siteBase}/${hub.slug}/`;
const childPages = (hub.childSlugs || []).map((slug) => relatedMap.get(slug)).filter(Boolean);
const schema = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: hub.heading,
description: hub.intro,
url: canonical,
inLanguage: 'de-DE',
image: siteBase + hub.heroImage,
mainEntity: childPages.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.title || item.heading,
url: `${siteBase}/${item.slug}/`
}))
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Start', item: siteBase + '/' },
{ '@type': 'ListItem', position: 2, name: seoHub.title, item: `${siteBase}/${seoHub.slug}/` },
{ '@type': 'ListItem', position: 3, name: hub.title, item: canonical }
]
}
];
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${escapeHtml(hub.title)} | bewertemich.de</title>
<meta name="description" content="${escapeHtml(hub.subtitle)}" />
<link rel="canonical" href="${canonical}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="${escapeHtml(hub.title)}" />
<meta property="og:description" content="${escapeHtml(hub.subtitle)}" />
<meta property="og:url" content="${canonical}" />
<meta property="og:image" content="${siteBase}${hub.heroImage}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeHtml(hub.title)}" />
<meta name="twitter:description" content="${escapeHtml(hub.subtitle)}" />
<meta name="twitter:image" content="${siteBase}${hub.heroImage}" />
<link rel="stylesheet" href="/landing.css" />
<script type="application/ld+json">${JSON.stringify(schema)}</script>
${trackingPixel(hub.slug, 'audience_hub')}
</head>
<body>
<main class="shell">
<section class="hero hero-split">
<div class="hero-copy">
<div class="pill-row">
<span class="pill">${escapeHtml(hub.accent)}</span>
<span class="pill">Community-Test</span>
<span class="pill">Upload-Route</span>
</div>
<h1>${escapeHtml(hub.heading)}</h1>
<p>${escapeHtml(hub.subtitle)}</p>
<p>${escapeHtml(hub.intro)}</p>
<div class="actions">
<a class="btn btn-primary" href="/upload?entry=${escapeAttr(hub.uploadEntry)}" ${seoTrackAttributes('seo_upload_click', hub.slug, 'audience-hero')}>${escapeHtml(hub.ctaLabel)}</a>
<a class="btn btn-secondary" href="/top10">Community Top 10 ansehen</a>
</div>
</div>
<div class="hero-visual">
<img class="hero-art" src="${hub.heroImage}" alt="${escapeHtml(hub.heroAlt)}" width="960" height="720" fetchpriority="high" decoding="async" />
<div class="hero-note">
<strong>Direkt von der Suchintention in den Test</strong>
<p>Dieser Hub bündelt passende Ratgeber, Beispiele und Upload-Routen, damit Besucher nicht in Text stecken bleiben.</p>
</div>
</div>
</section>
<section class="proof-strip">
${hub.proof.map((item) => `
<article class="proof-card">
<strong>${escapeHtml(item.title)}</strong>
<p>${escapeHtml(item.text)}</p>
</article>
`).join('')}
</section>
<section class="section-header">
<h2>Passende Tests und Ratgeber für ${escapeHtml(hub.accent)}</h2>
<p>Starte mit dem konkretesten Thema oder lade direkt ein Foto hoch. Jede Unterseite führt wieder in Upload, Invite-Link und Community-Bewertung.</p>
</section>
<section class="hub-grid">
${childPages.map((item) => `
<a class="hub-link" href="/${item.slug}/" ${seoTrackAttributes('seo_related_click', item.slug, 'audience-hub-grid')}>
<span class="eyebrow">${escapeHtml((item.tags || [hub.accent])[0])}</span>
<strong>${escapeHtml(item.title || item.heading)}</strong>
<span>${escapeHtml(item.subtitle || item.intro || '')}</span>
</a>
`).join('')}
</section>
<section class="media-panel">
<img src="${hub.heroImage}" alt="${escapeHtml(hub.heroAlt)}" width="960" height="720" loading="lazy" decoding="async" />
<div class="card">
<span class="eyebrow">Nächster Schritt</span>
<h2>Der beste Hub-Besuch endet nicht beim Lesen, sondern beim Test</h2>
<p>Bewertemich.de funktioniert als Kreislauf: Foto hochladen, 5 Kontakte einladen, Community-Stimmen sammeln und bei Unsicherheit eine zweite Variante testen.</p>
<ol class="list">
<li><strong>Foto auswählen:</strong> Nimm ein Bild, das du wirklich einsetzen würdest.</li>
<li><strong>Invite-Link teilen:</strong> Direkte Kontakte liefern die schnellste erste Tendenz.</li>
<li><strong>Signal nutzen:</strong> Gewinner übernehmen oder mit einer Alternative vergleichen.</li>
</ol>
<div class="actions">
<a class="btn btn-primary" href="/upload?entry=${escapeAttr(hub.uploadEntry)}" ${seoTrackAttributes('seo_upload_click', hub.slug, 'audience-media')}>${escapeHtml(hub.ctaLabel)}</a>
<a class="btn btn-secondary dark" href="/rate/random">Erst 5 Fotos mitbewerten</a>
</div>
</div>
</section>
<section class="card">
<div class="section-header compact">
<h2>Weitere Einstiege</h2>
<p>Wenn du noch nicht sicher bist, welcher Kontext passt, starte über den zentralen Ratgeber oder die öffentliche Topliste.</p>
</div>
<div class="related-grid">
<a class="related-link" href="/${seoHub.slug}/"><strong>Profilbild Ratgeber</strong><span>Alle Longtails und Entscheidungshilfen an einem Ort.</span></a>
<a class="related-link" href="/bestes-profilbild-finden/"><strong>Bestes Profilbild finden</strong><span>Wenn du zwischen mehreren Bildern festhängst.</span></a>
<a class="related-link" href="/top10"><strong>Community Top 10</strong><span>Öffentliche Rankings als Social-Proof-Einstieg.</span></a>
</div>
</section>
<section class="cta">
<h2>${escapeHtml(hub.ctaLabel)} und echte Stimmen sammeln</h2>
<p>Vom Hub direkt in den Produkt-Loop: Upload, Invite-Link, Community-Feedback.</p>
<div class="footer-actions">
<a class="btn btn-primary" href="/upload?entry=${escapeAttr(hub.uploadEntry)}" ${seoTrackAttributes('seo_upload_click', hub.slug, 'audience-footer')}>Upload starten</a>
<a class="btn btn-secondary" href="/profilbild-ratgeber/">Zum Ratgeber-Hub</a>
</div>
</section>
<div class="footer">bewertemich.de · ${escapeHtml(hub.title)} · Deutschland</div>
</main>
</body>
</html>`;
};
const renderHubPage = (relatedMap) => {
const canonical = `${siteBase}/${seoHub.slug}/`;
const cards = seoLandingCatalog.map((item) => `
<a class="hub-link" href="/${item.slug}/" ${seoTrackAttributes('seo_related_click', item.slug, 'hub-grid')}>
<span class="eyebrow">${escapeHtml(item.tags?.[0] || 'Profilbild')}</span>
<strong>${escapeHtml(item.title)}</strong>
<span>${escapeHtml(item.subtitle)}</span>
</a>
`).join('');
const schema = [{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: seoHub.heading,
description: seoHub.intro,
url: canonical
}];
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${escapeHtml(seoHub.title)} | bewertemich.de</title>
<meta name="description" content="${escapeHtml(seoHub.intro)}" />
<link rel="canonical" href="${canonical}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="${escapeHtml(seoHub.title)}" />
<meta property="og:description" content="${escapeHtml(seoHub.intro)}" />
<meta property="og:url" content="${canonical}" />
<meta property="og:image" content="${siteBase}${seoHub.heroImage}" />
<link rel="stylesheet" href="/landing.css" />
<script type="application/ld+json">${JSON.stringify(schema)}</script>
${trackingPixel(seoHub.slug, 'seo_hub')}
</head>
<body>
<main class="shell">
<section class="hero hero-split">
<div class="hero-copy">
<div class="pill-row">
<span class="pill">Hub</span>
<span class="pill">DE-SEO</span>
<span class="pill">Profilbild</span>
</div>
<h1>${escapeHtml(seoHub.heading)}</h1>
<p>${escapeHtml(seoHub.subtitle)}</p>
<p>${escapeHtml(seoHub.intro)}</p>
<div class="actions">
<a class="btn btn-primary" href="/upload?entry=seo_static_${slugifyEntry(seoHub.slug)}" ${seoTrackAttributes('seo_upload_click', seoHub.slug, 'hub-hero')}>Jetzt Foto testen</a>
<a class="btn btn-secondary" href="/top10">Toplisten ansehen</a>
</div>
</div>
<div class="hero-visual">
<img class="hero-art" src="${seoHub.heroImage}" alt="${escapeHtml(seoHub.heroAlt)}" width="960" height="720" fetchpriority="high" decoding="async" />
<div class="hero-note">
<strong>Skalierbarer DE-Longtail-Hub</strong>
<p>Alle relevanten Themen rund um Profilbilder, Dating-Fotos, Business-Portraits und Vergleiche laufen hier zusammen.</p>
</div>
</div>
</section>
<section class="section-header">
<h2>Alle Ratgeberseiten</h2>
<p>Von der Suchanfrage direkt in passende Tests und Vergleiche. Die vier Kernseiten bleiben prominent, der Rest deckt spezifische Longtails ab.</p>
</section>
<section class="hub-grid">
${cards}
</section>
<section class="card">
<div class="section-header compact">
<h2>Direkteinstiege</h2>
</div>
<div class="related-grid">
${featuredSeoLinks.map((item) => `
<a class="related-link" href="/${item.slug}/" ${seoTrackAttributes('seo_related_click', item.slug, 'featured')}>
<strong>${escapeHtml(item.title)}</strong>
<span>Zu diesem Thema direkt in die optimierte Landingpage springen.</span>
</a>
`).join('')}
</div>
</section>
<section class="cta">
<h2>Lieber direkt testen?</h2>
<p>Dann überspringst du die Theorie und startest sofort mit deinem Profilbild oder Vergleich.</p>
<div class="footer-actions">
<a class="btn btn-primary" href="/upload?entry=seo_static_${slugifyEntry(seoHub.slug)}" ${seoTrackAttributes('seo_upload_click', seoHub.slug, 'hub-footer')}>Upload starten</a>
<a class="btn btn-secondary" href="/top10">Toplisten ansehen</a>
</div>
</section>
<div class="footer">bewertemich.de · ${escapeHtml(seoHub.title)} · Deutschland</div>
</main>
</body>
</html>`;
};
const renderSitemap = () => {
const routes = [
{ path: '/', priority: '1.0' },
{ path: '/upload', priority: '0.9' },
{ path: '/top10', priority: '0.8' },
{ path: `/${seoHub.slug}/`, priority: '0.9', image: seoHub.heroImage, imageTitle: seoHub.title },
...audienceHubs.map((item) => ({ path: `/${item.slug}/`, priority: '0.9', image: item.heroImage, imageTitle: item.title })),
...seoLandingCatalog.map((item) => ({ path: `/${item.slug}/`, priority: '0.8', image: item.heroImage, imageTitle: item.title }))
];
const today = new Date().toISOString().slice(0, 10);
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${routes.map((route) => ` <url>
<loc>${siteBase}${route.path}</loc>
<lastmod>${today}</lastmod>
<changefreq>weekly</changefreq>
<priority>${route.priority}</priority>${route.image ? `
<image:image>
<image:loc>${siteBase}${route.image}</image:loc>
<image:title>${escapeHtml(route.imageTitle || 'bewertemich.de')}</image:title>
</image:image>` : ''}
</url>`).join('\n')}
</urlset>`;
};
const writePage = async (slug, html) => {
const dir = path.join(publicDir, slug);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.html'), html, 'utf8');
};
const buildSeoManifest = () => ({
generatedAt: new Date().toISOString(),
siteBase,
hub: { slug: seoHub.slug, title: seoHub.title, intro: seoHub.intro },
audienceHubs: audienceHubs.map((hub) => ({ slug: hub.slug, title: hub.title, tags: hub.tags || [], childSlugs: hub.childSlugs || [] })),
featuredLinks: featuredSeoLinks,
landingPages: seoLandingCatalog.map((page) => ({
slug: page.slug,
title: page.title,
tags: page.tags || [],
faqCount: (page.faqs || []).length
}))
});
const main = async () => {
const manifest = await readManifest();
const maps = buildMaps(manifest);
const relatedMap = buildRelatedMap();
for (const page of seoLandingCatalog) {
await writePage(page.slug, renderPage(page, maps, relatedMap));
}
for (const hub of audienceHubs) {
await writePage(hub.slug, renderAudienceHub(hub, relatedMap));
}
await writePage(seoHub.slug, renderHubPage(relatedMap));
await fs.writeFile(path.join(publicDir, 'sitemap.xml'), renderSitemap(), 'utf8');
await fs.writeFile(seoManifestPath, JSON.stringify(buildSeoManifest(), null, 2), 'utf8');
};
await main();