]> git.laktatnebel.de Git - websitegenerator.git/commitdiff
Debuggung-Session beendet
authorOle B. Rosentreter <ole@laktatnebel.de>
Mon, 23 Mar 2026 09:04:48 +0000 (10:04 +0100)
committerOle B. Rosentreter <ole@laktatnebel.de>
Mon, 23 Mar 2026 09:04:48 +0000 (10:04 +0100)
src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java
src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java
src/main/java/de/laktatnebel/product/websitegenerator/generator/ImageProcessor.java
src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java
src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java

index 9dde58b09d1163aef680d869d5be0395aac1ec3e..dd959a663374b21c73921d9f4fdef948cbeecb16 100644 (file)
@@ -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<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");
@@ -82,6 +98,18 @@ public class SiteConfig {
         // 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);
index 454cd9b2db81dd4ae291c4b8597cc92475925201..0feb04d1ee5986ec89511e7d1b4f97bbd125de78 100644 (file)
@@ -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<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")
@@ -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<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 {
index cd1d1ba0dba43deb689fdecc94417873bf3b877f..753d640707c14aeae20a14737e2b005a0be0406b 100644 (file)
@@ -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
index fc294ffb190cdbad5525f88d5197abe7c2e531fd..803bf4b52bf27267b3cb6e7d297c22a08a249abd 100644 (file)
@@ -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<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);
@@ -186,6 +212,7 @@ public class SiteGenerator {
 
     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());
@@ -199,16 +226,90 @@ public class SiteGenerator {
         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())) {
@@ -289,6 +390,48 @@ public class SiteGenerator {
     // 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 "";
 
@@ -361,7 +504,7 @@ public class SiteGenerator {
     }
 
     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();
index b2cdb2b2adb8d1441ab5df976cd45adb9331feca..4d21fa8ff1536982e50abb85189901fdd643d4e7 100644 (file)
@@ -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<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(",");
@@ -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(
+                "\\[([^\\]]+)\\]\\(([^)]+)\\)",
+                "<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,