Files
SAAS-Template/build.js
JUSN 6e83768b8c 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>
2026-08-14 22:43:01 +02:00

208 lines
11 KiB
JavaScript
Raw Permalink 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.
/**
* 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)`);