public final String contextPath; // z.B. '' (prod) oder '/projekte/www.triathlon-coaching.com/target/www' (dev)
public final String language;
public final String copyright;
+ public final String logo;
+ public final String socialInstagram;
// Database
public final String dbHost;
public final String dbSchema;
// Blog
- public final double navTagCoverage; // 0.0–1.0, z.B. 0.67 = Top-Tags die 2/3 abdecken
+ public final double navTagCoverage;
+ public final String galleryStyle;
+ // Business
+ public final String businessType;
+ public final String businessName;
+ public final String businessOwner;
+ public final String businessDescription;
+ public final String businessCity;
+ public final String businessCountry;
+ public final String businessEmail;
+ public final String businessFounded;
+ public final String businessPhone; // 0.0–1.0, z.B. 0.67 = Top-Tags die 2/3 abdecken
// Build
public final boolean cleanOutput;
baseUrl = (String) site.get("base_url");
contextPath = (String) site.getOrDefault("context_path", "");
language = (String) site.get("language");
- copyright = (String) site.get("copyright");
+ copyright = (String) site.get("copyright");
+ logo = (String) site.getOrDefault("logo", "");
+ Map<String, Object> social = site.containsKey("social") ? (Map<String, Object>) site.get("social") : Map.of();
+ socialInstagram = (String) social.getOrDefault("instagram", "");
// DB
dbHost = (String) db.get("host");
// Blog
Map<String, Object> blog = raw.containsKey("blog") ? (Map<String, Object>) raw.get("blog") : Map.of();
navTagCoverage = ((Number) blog.getOrDefault("nav_tag_coverage", 0.67)).doubleValue();
+ Map<String, Object> gallery = raw.containsKey("gallery") ? (Map<String, Object>) raw.get("gallery") : Map.of();
+ galleryStyle = (String) gallery.getOrDefault("style", "masonry");
+ Map<String, Object> biz = raw.containsKey("business") ? (Map<String, Object>) raw.get("business") : Map.of();
+ businessType = (String) biz.getOrDefault("type", "LocalBusiness");
+ businessName = (String) biz.getOrDefault("name", siteName);
+ businessOwner = (String) biz.getOrDefault("owner", "");
+ businessDescription = (String) biz.getOrDefault("description", "");
+ businessCity = (String) biz.getOrDefault("city", "");
+ businessCountry = (String) biz.getOrDefault("country", "DE");
+ businessEmail = (String) biz.getOrDefault("email", "");
+ businessFounded = (String) biz.getOrDefault("founded", "");
+ businessPhone = (String) biz.getOrDefault("phone", "");
// Build
cleanOutput = (Boolean) build.getOrDefault("clean_output", false);
// Blog-Cards (eigener Mapper – v_blog_cards hat weniger Spalten)
// ────────────────────────────────────────────────────────────────
+
+ /** Lädt alle published Child-Pages eines Parent-Slugs (für Tool-Grid) */
+ public List<PageView> findChildPages(String parentSlug) {
+ return db.jdbi().withHandle(h -> h
+ .createQuery("""
+ SELECT vc.*
+ FROM v_published_content vc
+ JOIN pages p ON p.slug = vc.slug
+ JOIN pages par ON par.id = p.parent_id
+ WHERE par.slug = :parentSlug
+ ORDER BY p.sort_order
+ """)
+ .bind("parentSlug", parentSlug)
+ .map(new PageViewMapper())
+ .list()
+ );
+ }
+
public List<BlogCard> findBlogCards() {
return db.withHandle(handle ->
handle.createQuery("SELECT * FROM v_blog_cards")
rs.getString("slug"),
rs.getString("template"),
rs.getString("locked_category"),
+ rs.getString("external_url"),
rs.getInt("content_id"),
rs.getString("title"),
rs.getString("subtitle"),
rs.getString("author_slug"),
rs.getString("hero_media_path"),
rs.getString("hero_alt_text"),
+ rs.getString("hero_style"),
+ rs.getString("hero_media_paths"),
+ rs.getString("hero_media_paths_md"),
rs.getString("card_media_path"),
rs.getString("card_alt_text"),
rs.getString("tag_slugs"),
}
}
+
+ // ────────────────────────────────────────────────────────────────
+ // Events
+ // ────────────────────────────────────────────────────────────────
+
+ public List<Event> findEvents() {
+ return db.jdbi().withHandle(h -> h
+ .createQuery("SELECT * FROM v_events WHERE status != 'past' ORDER BY date_start")
+ .map(new EventMapper())
+ .list()
+ );
+ }
+
+ public List<Event> findAllEvents() {
+ return db.jdbi().withHandle(h -> h
+ .createQuery("SELECT * FROM v_events ORDER BY date_start")
+ .map(new EventMapper())
+ .list()
+ );
+ }
+
+ public Event findEventByPageId(int pageId) {
+ return db.jdbi().withHandle(h -> h
+ .createQuery("SELECT * FROM v_events WHERE page_id = :pageId")
+ .bind("pageId", pageId)
+ .map(new EventMapper())
+ .findOne()
+ .orElse(null)
+ );
+ }
+
+ public Event findEventBySlug(String slug) {
+ return db.jdbi().withHandle(h -> h
+ .createQuery("SELECT * FROM v_events WHERE slug = :slug")
+ .bind("slug", slug)
+ .map(new EventMapper())
+ .findOne()
+ .orElse(null)
+ );
+ }
+
+ static class EventMapper implements RowMapper<Event> {
+ @Override
+ public Event map(ResultSet rs, StatementContext ctx) throws SQLException {
+ return new Event(
+ rs.getInt("id"),
+ rs.getString("slug"),
+ rs.getString("event_type"),
+ rs.getString("status"),
+ rs.getString("title"),
+ rs.getString("subtitle"),
+ rs.getString("location"),
+ rs.getObject("date_start", java.time.LocalDate.class),
+ rs.getObject("date_end", java.time.LocalDate.class),
+ rs.getObject("price_cents") != null ? rs.getInt("price_cents") : null,
+ rs.getString("price_note"),
+ rs.getObject("capacity") != null ? rs.getInt("capacity") : null,
+ rs.getString("body"),
+ rs.getString("teaser"),
+ rs.getString("signup_options"),
+ rs.getString("gallery_style"),
+ rs.getString("cover_lg"),
+ rs.getString("cover_md"),
+ rs.getString("cover_alt"),
+ rs.getString("gallery_lg_paths"),
+ rs.getString("gallery_md_paths"),
+ rs.getString("gallery_captions")
+ );
+ }
+ }
+
static class MediaMapper implements RowMapper<Media> {
@Override
public Media map(ResultSet rs, StatementContext ctx) throws SQLException {
// ────────────────────────────────────────────────────────────────
private boolean processImage(Media media) throws Exception {
- Path original = config.mediaOriginalsPath.resolve(media.pathOriginal());
+ // path_original kann sein:
+ // NULL → aus filename ableiten (mediaOriginalsPath/filename)
+ // Dateiname → gegen mediaOriginalsPath resolven
+ // Absoluter Pfad → direkt nutzen (Rückwärtskompatibilität)
+ String rawPath = (media.pathOriginal() != null && !media.pathOriginal().isBlank())
+ ? media.pathOriginal()
+ : media.filename();
+ Path original = rawPath.startsWith("/")
+ ? Path.of(rawPath)
+ : config.mediaOriginalsPath.resolve(rawPath);
if (!Files.exists(original)) {
log.warn("Original nicht gefunden: {}", original);
}
log.info("Verarbeite: {}", media.pathOriginal());
+ if (media.altText() == null || media.altText().isBlank()) {
+ log.warn("Kein Alt-Text für Bild: {} (id={})", media.filename(), media.id());
+ }
// Ausgabe-Dateiname: Originalname ohne Extension + Suffix
- // filename() bevorzugen, Fallback: letztes Segment aus pathOriginal
+ // filename() als Zielname – sauber und vom Original entkoppelt
String rawName = media.filename() != null
? media.filename()
: Path.of(media.pathOriginal()).getFileName().toString();
String baseName = baseName(rawName);
- String lgPath = "img/lg/" + baseName + "-1920.webp";
- String mdPath = "img/md/" + baseName + "-800.webp";
- String smPath = "img/sm/" + baseName + "-400.webp";
+ String lgPath = "media/img/lg/" + baseName + "-1920.webp";
+ String mdPath = "media/img/md/" + baseName + "-800.webp";
+ String smPath = "media/img/sm/" + baseName + "-400.webp";
// Verzeichnisse anlegen
Files.createDirectories(config.mediaOutputPath.resolve("img/lg"));
Files.createDirectories(config.mediaOutputPath.resolve("img/md"));
Files.createDirectories(config.mediaOutputPath.resolve("img/sm"));
+ // Pfad relativ zu www/ für DB und Templates
- // ImageMagick aufrufen
- convert(original, config.mediaOutputPath.resolve(lgPath), config.imageLg);
- convert(original, config.mediaOutputPath.resolve(mdPath), config.imageMd);
- convert(original, config.mediaOutputPath.resolve(smPath), config.imageSm);
+ // ImageMagick aufrufen – nur img/... relativ zu mediaOutputPath
+ convert(original, config.mediaOutputPath.resolve("img/lg/" + baseName + "-1920.webp"), config.imageLg);
+ convert(original, config.mediaOutputPath.resolve("img/md/" + baseName + "-800.webp"), config.imageMd);
+ convert(original, config.mediaOutputPath.resolve("img/sm/" + baseName + "-400.webp"), config.imageSm);
// DB aktualisieren
repo.updateMediaPaths(media.id(), lgPath, mdPath, smPath, hash);
ProcessBuilder pb = new ProcessBuilder(
cmd,
input.toString(),
+ "-auto-orient", // EXIF-Rotation anwenden (vor -strip!)
"-resize", width + "x>", // nur verkleinern, nie vergrößern
"-quality", String.valueOf(config.imageQuality),
"-strip", // Exif entfernen
import de.laktatnebel.product.websitegenerator.db.Database;
import de.laktatnebel.product.websitegenerator.db.PageRepository;
import de.laktatnebel.product.websitegenerator.model.Models.BlogCard;
+import de.laktatnebel.product.websitegenerator.model.Models.Event;
import de.laktatnebel.product.websitegenerator.model.Models.NavItem;
import de.laktatnebel.product.websitegenerator.model.Models.PageView;
import de.laktatnebel.product.websitegenerator.model.Models.Tag;
// ── 5b. Nav-Tags berechnen ────────────────────────
List<Tag> navTags = computeNavTags(allCards);
+ // ── 5c. Events laden ──────────────────────────────
+ List<Event> events = repo.findAllEvents();
+ log.info("Events: {} kommende", events.size());
+
// ── 6. Seiten generieren ──────────────────────────
List<PageView> pages = repo.findAllPublished();
+
+ // ── Tote interne Links prüfen ─────────────────────
+ Set<String> validSlugs = pages.stream()
+ .map(PageView::slug)
+ .collect(java.util.stream.Collectors.toSet());
+ validSlugs.add("index");
+ for (PageView p : pages) {
+ if (p.body() == null) continue;
+ java.util.regex.Matcher lm = java.util.regex.Pattern
+ .compile("\\]\\((?!http|mailto|#)([^)]+)\\)")
+ .matcher(p.body());
+ while (lm.find()) {
+ String href = lm.group(1).replaceFirst("^/", "").replaceFirst("/$", "");
+ if (!validSlugs.contains(href))
+ log.warn("Toter Link in '{}': /{}", p.slug(), href);
+ }
+ }
+
+
log.info("{} Seiten gefunden", pages.size());
for (PageView page : pages) {
+ // tool-card sind reine Daten – kein eigenes HTML
+ if ("tool-card".equals(page.template())) continue;
try {
- generatePage(page, navTree, navTags, allCards, allTags, md, ftl);
+ generatePage(page, navTree, navTags, allCards, allTags, events, repo, md, ftl);
built.incrementAndGet();
} catch (Exception e) {
log.error("Fehler bei Seite '{}': {}", page.slug(), e.getMessage(), e);
private void generatePage(PageView page, List<NavItem> navTree, List<Tag> navTags,
List<BlogCard> allCards, List<Tag> allTags,
+ List<Event> events, PageRepository repo,
MarkdownRenderer md, FreemarkerRenderer ftl) throws Exception {
log.debug("Generiere: /{} [{}]", page.slug(), page.template());
model.put("page", page);
model.put("bodyHtml", bodyHtml);
model.put("bodySecondaryHtml", bodySecondaryHtml);
+
+ // FAQ aus Body parsen
+ List<Map<String, String>> faqItems = parseFaq(page.body());
+ model.put("faqItems", faqItems);
+
+ // meta_desc: DB → excerpt → body-Anfang (max 160 Zeichen)
+ String autoMetaDesc = page.metaDesc() != null && !page.metaDesc().isBlank()
+ ? page.metaDesc()
+ : page.excerpt() != null && !page.excerpt().isBlank()
+ ? page.excerpt()
+ : page.body() != null && !page.body().isBlank()
+ ? page.body().replaceAll("[#*`\\[\\]()]", "").strip()
+ : "";
+ if (autoMetaDesc.length() > 160)
+ autoMetaDesc = autoMetaDesc.substring(0, 157) + "…";
+ model.put("metaDesc", autoMetaDesc);
+
+ // dateModified für JSON-LD
+ String dateModified = page.publishAt() != null
+ ? page.publishAt().toLocalDate().toString()
+ : "";
+ model.put("dateModified", dateModified);
+
+ // Breadcrumbs
+ List<Map<String, String>> breadcrumbs = new java.util.ArrayList<>();
+ breadcrumbs.add(Map.of("name", "Start", "url", config.contextPath + "/"));
+ if (!"index".equals(page.slug())) {
+ String[] parts = page.slug().split("/");
+ StringBuilder pathSoFar = new StringBuilder(config.contextPath);
+ for (int i = 0; i < parts.length; i++) {
+ pathSoFar.append("/").append(parts[i]);
+ String label = i == parts.length - 1
+ ? page.title()
+ : parts[i].replace("-", " ");
+ if (!label.isEmpty())
+ label = Character.toUpperCase(label.charAt(0)) + label.substring(1);
+ breadcrumbs.add(Map.of("name", label, "url", pathSoFar.toString()));
+ }
+ }
+ model.put("breadcrumbs", breadcrumbs);
+ Map<String, String> businessMap = Map.of(
+ "type", config.businessType,
+ "name", config.businessName,
+ "owner", config.businessOwner,
+ "description", config.businessDescription,
+ "city", config.businessCity,
+ "country", config.businessCountry,
+ "email", config.businessEmail,
+ "founded", config.businessFounded,
+ "phone", config.businessPhone
+ );
+ model.put("business", businessMap);
model.put("nav", navTree);
model.put("site", Map.of(
"name", config.siteName,
"baseUrl", config.baseUrl,
"contextPath", config.contextPath,
"language", config.language,
- "copyright", config.copyright
+ "copyright", config.copyright,
+ "socialInstagram", config.socialInstagram,
+ "logo", config.logo,
+ "galleryStyle", config.galleryStyle
));
model.put("navTags", navTags);
+ // Events: für camps-grid und event Templates
+ if ("camps-grid".equals(page.template())) {
+ model.put("events", events);
+ }
+ if ("event".equals(page.template())) {
+ Event event = repo.findEventByPageId(page.pageId());
+ model.put("event", event);
+ model.put("events", events); // für "Weitere Events"
+ if (event != null) {
+ model.put("eventBodyHtml", md.render(event.body() != null ? event.body() : ""));
+ }
+ }
+
+ // Tool-Cards: Child-Pages von tools laden
+ if ("tool-grid".equals(page.template())) {
+ List<PageView> toolCards = repo.findChildPages(page.slug());
+ model.put("tools", toolCards);
+ }
+
if ("blog-grid".equals(page.template())
|| "category-grid".equals(page.template())
|| "article".equals(page.template())) {
// Markdown: ## JAHR – Titel\n\nText → <div class="tl-item">
// ────────────────────────────────────────────────────────────────
+
+ /**
+ * Parst FAQ-Abschnitte aus Markdown.
+ * Erkennt ## FAQ / ## Häufige Fragen gefolgt von ### Frage + Antworttext.
+ * Gibt Liste von {q, a} Maps zurück.
+ */
+ private List<Map<String, String>> parseFaq(String markdown) {
+ if (markdown == null || markdown.isBlank()) return List.of();
+ List<Map<String, String>> faqs = new java.util.ArrayList<>();
+
+ // FAQ-Abschnitt finden
+ java.util.regex.Pattern faqSection = java.util.regex.Pattern.compile(
+ "(?im)^##\\s+(FAQ|Häufige Fragen|Häufige Fragen|Fragen und Antworten)\\s*$"
+ );
+ java.util.regex.Matcher sectionMatcher = faqSection.matcher(markdown);
+ if (!sectionMatcher.find()) return List.of();
+
+ String faqPart = markdown.substring(sectionMatcher.end());
+ // Bis zum nächsten ## abschneiden
+ java.util.regex.Pattern nextH2 = java.util.regex.Pattern.compile("(?m)^##\\s+(?!#)");
+ java.util.regex.Matcher nextH2Matcher = nextH2.matcher(faqPart);
+ if (nextH2Matcher.find()) faqPart = faqPart.substring(0, nextH2Matcher.start());
+
+ // ### Fragen parsen
+ String[] qBlocks = faqPart.split("(?m)^###\\s+");
+ for (String block : qBlocks) {
+ block = block.trim();
+ if (block.isBlank()) continue;
+ int nl = block.indexOf('\n');
+ if (nl < 0) continue;
+ String question = block.substring(0, nl).trim();
+ String answer = block.substring(nl).trim()
+ .replaceAll("(?m)^#{1,6}\\s+", "") // Headings entfernen
+ .replaceAll("\\*{1,2}([^*]+)\\*{1,2}", "$1") // Bold/Italic
+ .replaceAll("\\[([^\\]]+)\\]\\([^)]+\\)", "$1") // Links
+ .replaceAll("\n+", " ").strip();
+ if (!question.isBlank() && !answer.isBlank())
+ faqs.add(Map.of("q", question, "a", answer));
+ }
+ return faqs;
+ }
+
private String renderTimeline(String markdown) {
if (markdown == null || markdown.isBlank()) return "";
}
private void generateSitemap(List<PageView> pages) throws IOException {
- if (dryRun || config.baseUrl.startsWith("http://localhost")) return;
+ if (dryRun) return;
DateTimeFormatter iso = DateTimeFormatter.ofPattern("yyyy-MM-dd");
StringBuilder sb = new StringBuilder();
String slug,
String template,
String lockedCategory,
+ String externalUrl,
int contentId,
String title,
String subtitle,
String authorSlug,
String heroMediaPath,
String heroAltText,
+ String heroStyle,
+ String heroMediaPaths,
+ String heroMediaPathsMd,
String cardMediaPath,
String cardAltText,
String tagSlugs,
return publishAt != null ? publishAt.format(ISO_FMT) : "";
}
+ /** Alle Hero-Bilder als Liste – für carousel/slideshow/random */
+ public List<String> heroImages() {
+ if (heroMediaPaths == null || heroMediaPaths.isBlank()) {
+ return heroMediaPath != null ? List.of(heroMediaPath) : List.of();
+ }
+ return List.of(heroMediaPaths.split(","));
+ }
+ public List<String> heroImagesMd() {
+ if (heroMediaPathsMd == null || heroMediaPathsMd.isBlank()) {
+ return heroMediaPath != null ? List.of(heroMediaPath) : List.of();
+ }
+ return List.of(heroMediaPathsMd.split(","));
+ }
+
public List<Tag> tags() {
if (tagSlugs == null || tagSlugs.isBlank()) return List.of();
String[] slugs = tagSlugs.split(",");
// ============================================================
// NavItem – Navigationseintrag mit Kindern
// ============================================================
+
+ // ============================================================
+ // Event – aus v_events
+ // ============================================================
+ public record Event(
+ int id,
+ String slug,
+ String eventType,
+ String status,
+ String title,
+ String subtitle,
+ String location,
+ java.time.LocalDate dateStart,
+ java.time.LocalDate dateEnd,
+ Integer priceCents,
+ String priceNote,
+ Integer capacity,
+ String body,
+ String teaser,
+ String signupOptions, // Markdown mit buchbaren Optionen
+ String galleryStyle, // null = Fallback auf config.yaml
+ String coverLg,
+ String coverMd,
+ String coverAlt,
+ String galleryLgPaths, // komma-separiert
+ String galleryMdPaths,
+ String galleryCaptions // ||| separiert
+ ) {
+ /** Preis formatiert, z.B. "1.499,00 €" */
+ public String priceFormatted() {
+ if (priceCents == null) return "";
+ return String.format("%,.2f\u00A0€", priceCents / 100.0)
+ .replace('.', 'X').replace(',', '.').replace('X', ',');
+ }
+
+ /** Datumsbereich formatiert, z.B. "2.–9. Mai 2026" */
+ public String dateRange() {
+ if (dateStart == null) return "";
+ java.time.format.DateTimeFormatter dayFmt =
+ java.time.format.DateTimeFormatter.ofPattern("d.", java.util.Locale.GERMAN);
+ java.time.format.DateTimeFormatter fullFmt =
+ java.time.format.DateTimeFormatter.ofPattern("d. MMMM yyyy", java.util.Locale.GERMAN);
+ if (dateEnd == null || dateEnd.equals(dateStart))
+ return dateStart.format(fullFmt);
+ if (dateStart.getMonth() == dateEnd.getMonth() && dateStart.getYear() == dateEnd.getYear())
+ return dateStart.format(dayFmt) + "–" + dateEnd.format(fullFmt);
+ return dateStart.format(fullFmt) + " – " + dateEnd.format(fullFmt);
+ }
+
+ /**
+ * Löst Markdown-Links [Text](/pfad) in HTML auf.
+ * [event] wird durch den Event-Titel ersetzt.
+ */
+ public String resolveLinks(String text, String contextPath) {
+ if (text == null) return "";
+ String t = text.replace("[event]", title());
+ return t.replaceAll(
+ "\\[([^\\]]+)\\]\\(([^)]+)\\)",
+ "<a href=\"" + contextPath + "$2\" target=\"_blank\" style=\"white-space:nowrap\">$1</a>"
+ );
+ }
+
+ public List<String> galleryLg() {
+ if (galleryLgPaths == null || galleryLgPaths.isBlank()) return List.of();
+ return List.of(galleryLgPaths.split(","));
+ }
+ public List<String> galleryMd() {
+ if (galleryMdPaths == null || galleryMdPaths.isBlank()) return List.of();
+ return List.of(galleryMdPaths.split(","));
+ }
+ public List<String> captions() {
+ if (galleryCaptions == null || galleryCaptions.isBlank()) return List.of();
+ return List.of(galleryCaptions.split("\\|\\|\\|"));
+ }
+ }
+
public record NavItem(
int id,
Integer parentId,