From: Ole B. Rosentreter Date: Mon, 23 Mar 2026 09:04:48 +0000 (+0100) Subject: Debuggung-Session beendet X-Git-Url: https://git.laktatnebel.de/?a=commitdiff_plain;h=aff210f76771aee3ceaecbc796cea472ac2b73f0;p=websitegenerator.git Debuggung-Session beendet --- diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java index 9dde58b..dd959a6 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java @@ -20,6 +20,8 @@ public class SiteConfig { 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; @@ -37,7 +39,18 @@ public class SiteConfig { 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; @@ -61,7 +74,10 @@ public class SiteConfig { 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 social = site.containsKey("social") ? (Map) site.get("social") : Map.of(); + socialInstagram = (String) social.getOrDefault("instagram", ""); // DB dbHost = (String) db.get("host"); @@ -82,6 +98,18 @@ public class SiteConfig { // Blog Map blog = raw.containsKey("blog") ? (Map) raw.get("blog") : Map.of(); navTagCoverage = ((Number) blog.getOrDefault("nav_tag_coverage", 0.67)).doubleValue(); + Map gallery = raw.containsKey("gallery") ? (Map) raw.get("gallery") : Map.of(); + galleryStyle = (String) gallery.getOrDefault("style", "masonry"); + Map biz = raw.containsKey("business") ? (Map) 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); diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java b/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java index 454cd9b..0feb04d 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java @@ -61,6 +61,24 @@ public class PageRepository { // 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 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 findBlogCards() { return db.withHandle(handle -> handle.createQuery("SELECT * FROM v_blog_cards") @@ -166,6 +184,7 @@ public class PageRepository { rs.getString("slug"), rs.getString("template"), rs.getString("locked_category"), + rs.getString("external_url"), rs.getInt("content_id"), rs.getString("title"), rs.getString("subtitle"), @@ -182,6 +201,9 @@ public class PageRepository { 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"), @@ -227,6 +249,77 @@ public class PageRepository { } } + + // ──────────────────────────────────────────────────────────────── + // Events + // ──────────────────────────────────────────────────────────────── + + public List 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 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 { + @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 { @Override public Media map(ResultSet rs, StatementContext ctx) throws SQLException { diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java b/src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java index cd1d1ba..753d640 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java @@ -67,7 +67,16 @@ public class ImageProcessor { // ──────────────────────────────────────────────────────────────── 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); @@ -86,27 +95,31 @@ public class ImageProcessor { } 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); @@ -122,6 +135,7 @@ public class ImageProcessor { 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 diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java b/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java index fc294ff..803bf4b 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java @@ -5,6 +5,7 @@ import de.laktatnebel.product.websitegenerator.config.SiteConfig; 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; @@ -70,13 +71,38 @@ public class SiteGenerator { // ── 5b. Nav-Tags berechnen ──────────────────────── List navTags = computeNavTags(allCards); + // ── 5c. Events laden ────────────────────────────── + List events = repo.findAllEvents(); + log.info("Events: {} kommende", events.size()); + // ── 6. Seiten generieren ────────────────────────── List pages = repo.findAllPublished(); + + // ── Tote interne Links prüfen ───────────────────── + Set 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); @@ -186,6 +212,7 @@ public class SiteGenerator { private void generatePage(PageView page, List navTree, List navTags, List allCards, List allTags, + List events, PageRepository repo, MarkdownRenderer md, FreemarkerRenderer ftl) throws Exception { log.debug("Generiere: /{} [{}]", page.slug(), page.template()); @@ -199,16 +226,90 @@ public class SiteGenerator { model.put("page", page); model.put("bodyHtml", bodyHtml); model.put("bodySecondaryHtml", bodySecondaryHtml); + + // FAQ aus Body parsen + List> 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> 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 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 toolCards = repo.findChildPages(page.slug()); + model.put("tools", toolCards); + } + if ("blog-grid".equals(page.template()) || "category-grid".equals(page.template()) || "article".equals(page.template())) { @@ -289,6 +390,48 @@ public class SiteGenerator { // Markdown: ## JAHR – Titel\n\nText →
// ──────────────────────────────────────────────────────────────── + + /** + * Parst FAQ-Abschnitte aus Markdown. + * Erkennt ## FAQ / ## Häufige Fragen gefolgt von ### Frage + Antworttext. + * Gibt Liste von {q, a} Maps zurück. + */ + private List> parseFaq(String markdown) { + if (markdown == null || markdown.isBlank()) return List.of(); + List> 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 ""; @@ -361,7 +504,7 @@ public class SiteGenerator { } private void generateSitemap(List 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(); diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java b/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java index b2cdb2b..4d21fa8 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java @@ -18,6 +18,7 @@ public class Models { String slug, String template, String lockedCategory, + String externalUrl, int contentId, String title, String subtitle, @@ -34,6 +35,9 @@ public class Models { String authorSlug, String heroMediaPath, String heroAltText, + String heroStyle, + String heroMediaPaths, + String heroMediaPathsMd, String cardMediaPath, String cardAltText, String tagSlugs, @@ -53,6 +57,20 @@ public class Models { return publishAt != null ? publishAt.format(ISO_FMT) : ""; } + /** Alle Hero-Bilder als Liste – für carousel/slideshow/random */ + public List heroImages() { + if (heroMediaPaths == null || heroMediaPaths.isBlank()) { + return heroMediaPath != null ? List.of(heroMediaPath) : List.of(); + } + return List.of(heroMediaPaths.split(",")); + } + public List heroImagesMd() { + if (heroMediaPathsMd == null || heroMediaPathsMd.isBlank()) { + return heroMediaPath != null ? List.of(heroMediaPath) : List.of(); + } + return List.of(heroMediaPathsMd.split(",")); + } + public List tags() { if (tagSlugs == null || tagSlugs.isBlank()) return List.of(); String[] slugs = tagSlugs.split(","); @@ -128,6 +146,82 @@ public class Models { // ============================================================ // 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( + "\\[([^\\]]+)\\]\\(([^)]+)\\)", + "$1" + ); + } + + public List galleryLg() { + if (galleryLgPaths == null || galleryLgPaths.isBlank()) return List.of(); + return List.of(galleryLgPaths.split(",")); + } + public List galleryMd() { + if (galleryMdPaths == null || galleryMdPaths.isBlank()) return List.of(); + return List.of(galleryMdPaths.split(",")); + } + public List captions() { + if (galleryCaptions == null || galleryCaptions.isBlank()) return List.of(); + return List.of(galleryCaptions.split("\\|\\|\\|")); + } + } + public record NavItem( int id, Integer parentId,