Landingpage-Template v1: config-gesteuert, zwei Themes (papier/clean)

- config.js: alle Inhalte an einer Stelle (Marke, SEO, Farben, Hero,
  Features, Zahlen, Schritte, Warteliste, FAQ, Footer)
- build.js: rendert statisches HTML aus der Config (gut fuer SEO),
  Abschnitte optional, Theme papier/clean, Assets-Kopie
- README mit Anleitung, .gitignore
- BehördenKlar als fertiges Beispiel; node build.js getestet -> dist/index.html

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 22:43:01 +02:00
commit 6e83768b8c
5 changed files with 510 additions and 0 deletions

207
build.js Normal file
View File

@@ -0,0 +1,207 @@
/**
* BUILD — macht aus config.js die fertige dist/index.html.
* Aufruf: node build.js
*
* Hier steckt das DESIGN. Normalerweise musst du diese Datei nicht anfassen —
* nur config.js ausfüllen. Wer das Layout ändern will, ist hier richtig.
*/
const fs = require('fs');
const path = require('path');
const c = require('./config.js');
// kleine Helfer -------------------------------------------------------
const esc = (s = '') => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const has = (x) => Array.isArray(x) ? x.length > 0 : !!x;
const papier = c.theme !== 'clean'; // Standard = Papier-Look
// CSS ----------------------------------------------------------------
const css = `
:root{
--bg:${c.colors.bg}; --text:${c.colors.text}; --accent:${c.colors.accent};
--marker:${c.colors.marker};
--grau:color-mix(in srgb, var(--text) 55%, transparent);
--linie:color-mix(in srgb, var(--text) 18%, transparent);
color-scheme:light;
}
*{margin:0;padding:0;box-sizing:border-box}
html{scroll-behavior:smooth}
body{
font-family:${papier ? "Georgia,'Times New Roman',serif" : "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif"};
color:var(--text); line-height:1.7; font-size:17px;
background:${papier
? "repeating-linear-gradient(0deg,transparent 0 34px,rgba(0,0,0,.025) 34px 35px),var(--bg)"
: "var(--bg)"};
}
.wrap{max-width:720px;margin:0 auto;padding:0 20px 72px}
a{color:var(--accent)}
.kopf{display:flex;justify-content:space-between;align-items:baseline;padding:20px 0 12px;border-bottom:2px solid var(--text);font-size:15px}
.marke{font-weight:700;font-size:17px;text-decoration:none;color:var(--text)}
header{padding:44px 0 8px}
.kicker{font-size:13px;color:var(--grau);text-transform:uppercase;letter-spacing:.14em;margin-bottom:10px}
h1{font-size:clamp(34px,7vw,52px);line-height:1.12;font-weight:700;letter-spacing:-.01em}
.mark{background:linear-gradient(104deg,transparent 2%,var(--marker) 4%,var(--marker) 96%,transparent 98%);padding:0 6px;box-decoration-break:clone;-webkit-box-decoration-break:clone}
.anrede{margin-top:24px;font-size:19px;max-width:560px}
.hero-fuss{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:30px}
.cta{display:inline-block;padding:16px 28px;background:var(--accent);color:#fff;text-decoration:none;font-family:inherit;font-size:17px;font-weight:700;border:2px solid var(--text);box-shadow:5px 5px 0 var(--text);transition:transform .12s,box-shadow .12s}
.cta:hover{transform:translate(2px,2px);box-shadow:3px 3px 0 var(--text)}
.cta-sek{background:#fff;color:var(--accent)}
.stempel{display:inline-block;padding:7px 14px;border:2.5px solid var(--accent);color:var(--accent);font-family:${papier ? "'Courier New',monospace" : 'inherit'};font-weight:700;font-size:14px;letter-spacing:.1em;text-transform:uppercase;${papier ? 'transform:rotate(-4deg);' : 'border-radius:6px;'}opacity:.9}
section{margin-top:60px}
h2{font-size:15px;text-transform:uppercase;letter-spacing:.14em;color:var(--grau);font-weight:400;margin-bottom:22px;display:flex;align-items:center;gap:12px}
h2::after{content:'';flex:1;border-top:1px solid var(--linie)}
h2 .nr{font-family:'Courier New',monospace}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:560px){.grid{grid-template-columns:1fr}}
.karte{background:#fff;border:1px solid var(--linie);padding:20px 22px;${papier ? 'box-shadow:4px 5px 0 rgba(0,0,0,.06);' : 'border-radius:12px;'}}
.karte .ico{font-size:26px}
.karte h3{font-size:19px;margin:8px 0 4px}
.karte p{font-size:15px;color:var(--grau)}
.zahl{font-size:38px;font-weight:700;color:var(--accent);line-height:1.1}
.zahl-erkl{font-size:15px;color:var(--grau);margin-top:6px}
.zahl-q{font-size:12px;color:var(--grau);margin-top:8px;font-family:'Courier New',monospace}
.schritt{display:flex;gap:20px;padding:18px 4px;border-bottom:1px solid var(--linie);align-items:baseline}
.schritt:last-child{border-bottom:none}
.schritt .n{font-family:'Courier New',monospace;font-size:28px;font-weight:700;color:var(--accent);width:48px;flex-shrink:0}
.schritt strong{font-size:19px;display:block;margin-bottom:2px}
.schritt span{color:var(--grau);font-size:16px}
.antrag{background:#fff;border:2px solid var(--text);${papier ? 'box-shadow:7px 8px 0 rgba(0,0,0,.85);' : 'border-radius:14px;box-shadow:0 4px 16px rgba(0,0,0,.08);'}padding:28px 26px}
.antrag h3{font-size:26px;line-height:1.25}
.antrag p{margin-top:12px;max-width:520px}
.bonus{margin:16px 0;padding:12px 16px;background:linear-gradient(104deg,transparent 1%,var(--marker) 3%,var(--marker) 97%,transparent 99%);font-size:16px}
.wl-form{display:flex;flex-direction:column;gap:12px;margin-top:6px}
.wl-input{width:100%;padding:15px 16px;font-size:17px;font-family:inherit;border:2px solid var(--text);background:#fff;color:var(--text)}
.wl-btn{width:100%;cursor:pointer;text-align:center;font-family:inherit}
.klein{font-size:13.5px;color:var(--grau);margin-top:12px}
details{border-bottom:1px solid var(--linie);padding:14px 4px}
summary{font-weight:700;cursor:pointer;font-size:17px;list-style:none;display:flex;justify-content:space-between;gap:12px}
summary::-webkit-details-marker{display:none}
summary::after{content:'+';font-family:'Courier New',monospace;font-size:22px;color:var(--accent)}
details[open] summary::after{content:''}
details p{margin-top:10px;color:var(--grau);font-size:16px;max-width:580px}
footer{margin-top:56px;padding-top:16px;border-top:2px solid var(--text);font-size:14.5px;color:var(--grau)}
footer a{color:var(--grau)}
`;
// Abschnitte ---------------------------------------------------------
const nr = (n) => papier ? `<span class="nr">§ ${n}</span> ` : '';
let s = 0; // Abschnittszähler
function features() {
if (!has(c.features)) return '';
s++;
return `<section><h2>${nr(s)}${esc(c.featuresTitle || 'Vorteile')}</h2>
<div class="grid">${c.features.map(f => `
<div class="karte">${f.icon ? `<div class="ico">${esc(f.icon)}</div>` : ''}
<h3>${esc(f.title)}</h3><p>${esc(f.text)}</p></div>`).join('')}
</div></section>`;
}
function stats() {
if (!has(c.stats)) return '';
s++;
return `<section><h2>${nr(s)}In Zahlen</h2>
<div class="grid">${c.stats.map(z => `
<div class="karte"><div class="zahl">${esc(z.value)}</div>
<div class="zahl-erkl">${esc(z.label)}</div>
${z.source ? `<div class="zahl-q">Quelle: ${esc(z.source)}</div>` : ''}</div>`).join('')}
</div></section>`;
}
function steps() {
if (!has(c.steps)) return '';
s++;
return `<section><h2>${nr(s)}${esc(c.stepsTitle || 'So funktioniert es')}</h2>
${c.steps.map((st, i) => `
<div class="schritt"><div class="n">${String(i + 1).padStart(2, '0')}</div>
<div><strong>${esc(st.title)}</strong><span>${esc(st.text)}</span></div></div>`).join('')}
</section>`;
}
function waitlist() {
const w = c.waitlist; if (!w || !w.title) return '';
s++;
const form = w.formAction
? `<form class="wl-form" action="${esc(w.formAction)}" method="POST">
<input class="wl-input" type="email" name="EMAIL" placeholder="${esc(w.emailPlaceholder || 'E-Mail')}" required>
<input type="text" name="email_address_check" value="" style="position:absolute;left:-5000px" tabindex="-1" aria-hidden="true">
<input type="hidden" name="locale" value="${esc(c.brand.lang || 'de')}"><input type="hidden" name="html_type" value="simple">
<button class="cta wl-btn" type="submit">${esc(w.buttonLabel || 'Eintragen')}</button>
</form>`
: `<a class="cta" style="width:auto" href="mailto:?subject=${encodeURIComponent(c.brand.name + ' Warteliste')}">${esc(w.buttonLabel || 'Per E-Mail eintragen')}</a>`;
return `<section id="${esc(w.id || 'anmelden')}"><h2>${nr(s)}Warteliste</h2>
<div class="antrag"><h3>${esc(w.title)}</h3>
${w.text ? `<p>${esc(w.text)}</p>` : ''}
${w.bonus ? `<p class="bonus">${esc(w.bonus)}</p>` : ''}
${form}
${w.note ? `<p class="klein">${esc(w.note)}</p>` : ''}
</div></section>`;
}
function faq() {
if (!has(c.faq)) return '';
s++;
return `<section><h2>${nr(s)}${esc(c.faqTitle || 'Häufige Fragen')}</h2>
${c.faq.map(f => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}
</section>`;
}
// Seite zusammenbauen ------------------------------------------------
const h = c.hero || {};
const secondary = h.secondaryCta && h.secondaryCta.href !== h.primaryCta?.href
? `<a class="cta cta-sek" href="${esc(h.secondaryCta.href)}">${esc(h.secondaryCta.label)}</a>` : '';
const html = `<!DOCTYPE html>
<html lang="${esc(c.brand.lang || 'de')}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(c.seo.title || c.brand.name)}</title>
<meta name="description" content="${esc(c.seo.description || '')}">
${c.brand.url ? `<link rel="canonical" href="${esc(c.brand.url)}/">` : ''}
<meta property="og:type" content="website">
<meta property="og:title" content="${esc(c.seo.title || c.brand.name)}">
<meta property="og:description" content="${esc(c.seo.description || '')}">
${c.seo.ogImage ? `<meta property="og:image" content="${esc(c.seo.ogImage)}">` : ''}
<meta name="twitter:card" content="summary_large_image">
<style>${css}</style>
</head>
<body>
<div class="wrap">
<div class="kopf"><a class="marke" href="/">${c.brand.emoji ? esc(c.brand.emoji) + ' ' : ''}${esc(c.brand.name)}</a></div>
<header>
${h.kicker ? `<p class="kicker">${esc(h.kicker)}</p>` : ''}
<h1>${esc(h.title || c.brand.name)}${h.titleHighlight ? `<br><span class="mark">${esc(h.titleHighlight)}</span>` : ''}</h1>
${h.text ? `<p class="anrede">${esc(h.text)}</p>` : ''}
<div class="hero-fuss">
${h.primaryCta ? `<a class="cta" href="${esc(h.primaryCta.href)}">${esc(h.primaryCta.label)}</a>` : ''}
${secondary}
${h.stamp ? `<span class="stempel">${esc(h.stamp)}</span>` : ''}
</div>
</header>
${features()}
${stats()}
${steps()}
${waitlist()}
${faq()}
<footer>
${(c.footer?.links || []).map(l => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join(' · ')}
${c.footer?.note ? `<p style="margin-top:10px">${esc(c.footer.note)}</p>` : ''}
</footer>
</div>
${c.analyticsToken ? `<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "${esc(c.analyticsToken)}"}'></script>` : ''}
</body>
</html>`;
// schreiben ----------------------------------------------------------
const out = path.join(__dirname, 'dist');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, 'index.html'), html);
// assets/ (Favicon etc.) mitkopieren, falls vorhanden
const assets = path.join(__dirname, 'assets');
if (fs.existsSync(assets)) {
for (const f of fs.readdirSync(assets)) fs.copyFileSync(path.join(assets, f), path.join(out, f));
}
console.log(`✓ Fertig: dist/index.html (Theme: ${c.theme}, ${s} Abschnitte)`);