]> git.laktatnebel.de Git - websitegenerator.git/commitdiff
Java-Files aus AI eingebaut
authorOle B. Rosentreter <ole@laktatnebel.de>
Fri, 13 Mar 2026 22:06:35 +0000 (23:06 +0100)
committerOle B. Rosentreter <ole@laktatnebel.de>
Fri, 13 Mar 2026 22:06:35 +0000 (23:06 +0100)
pom.xml
src/main/java/de/laktatnebel/product/websitegenerator/BuildResult.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/Main.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/renderer/MarkdownRenderer.java [new file with mode: 0644]
src/main/resources/logback.xml [new file with mode: 0644]

diff --git a/pom.xml b/pom.xml
index d3015526ab1531c8f76651bce623afb643e211ae..69e8539f3a0ecb186612e748a74e68e92ae3d7f5 100644 (file)
--- a/pom.xml
+++ b/pom.xml
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <groupId>de.laktatnebel.product</groupId>
        <artifactId>websitegenerator</artifactId>
        <version>0.0.1-SNAPSHOT</version>
-       <packaging>pom</packaging>
-
-       <name>websitegenerator</name>
+       <packaging>jar</packaging>
 
        <parent>
                <groupId>de.laktatnebel.maven</groupId>
-               <artifactId>laktatnebelproductmaster</artifactId>
-               <version>2.1.9</version>
+               <artifactId>laktatnebelproductstandalone-plain</artifactId>
+               <version>3.0.0</version>
        </parent>
 
+       <name>laktatnebel.de Static Site Generator</name>
+
+       <properties>
+               <filelib.version>2.1.0</filelib.version>
+               <dblib.version>2.1.0</dblib.version>
+               <propertylib.version>2.1.0</propertylib.version>
+               <commons-lang.version>2.6</commons-lang.version>
+
+               <main.class>${project.groupId}.${project.artifactId}.Main</main.class>
+               <project.scm.dir>${project.artifactId}</project.scm.dir>
+
+               <!-- SSG-spezifische Versionen -->
+               <jdbi.version>3.45.1</jdbi.version>
+               <hikari.version>5.1.0</hikari.version>
+               <postgresql.version>42.7.3</postgresql.version>
+               <freemarker.version>2.3.33</freemarker.version>
+               <commonmark.version>0.25.0</commonmark.version>
+               <snakeyaml.version>2.2</snakeyaml.version>
+       </properties>
+
+       <dependencyManagement>
+               <dependencies>
+                       <dependency>
+                               <groupId>commons-lang</groupId>
+                               <artifactId>commons-lang</artifactId>
+                               <version>${commons-lang.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>de.laktatnebel.libs</groupId>
+                               <artifactId>filelib</artifactId>
+                               <version>${filelib.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>de.laktatnebel.libs</groupId>
+                               <artifactId>dblib</artifactId>
+                               <version>${dblib.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>de.laktatnebel.libs</groupId>
+                               <artifactId>propertylib</artifactId>
+                               <version>${propertylib.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>commons-io</groupId>
+                               <artifactId>commons-io</artifactId>
+                               <version>1.3.2</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>commons-codec</groupId>
+                               <artifactId>commons-codec</artifactId>
+                               <version>1.10</version>
+                       </dependency>
+
+                       <!-- ── Datenbank ─────────────────────────────────── -->
+                       <dependency>
+                               <groupId>org.jdbi</groupId>
+                               <artifactId>jdbi3-core</artifactId>
+                               <version>${jdbi.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>org.jdbi</groupId>
+                               <artifactId>jdbi3-sqlobject</artifactId>
+                               <version>${jdbi.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>com.zaxxer</groupId>
+                               <artifactId>HikariCP</artifactId>
+                               <version>${hikari.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>org.postgresql</groupId>
+                               <artifactId>postgresql</artifactId>
+                               <version>${postgresql.version}</version>
+                       </dependency>
+
+                       <!-- ── Templating ────────────────────────────────── -->
+                       <dependency>
+                               <groupId>org.freemarker</groupId>
+                               <artifactId>freemarker</artifactId>
+                               <version>${freemarker.version}</version>
+                       </dependency>
+
+                       <!-- ── Markdown ──────────────────────────────────── -->
+                       <dependency>
+                               <groupId>org.commonmark</groupId>
+                               <artifactId>commonmark</artifactId>
+                               <version>${commonmark.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>org.commonmark</groupId>
+                               <artifactId>commonmark-ext-gfm-tables</artifactId>
+                               <version>${commonmark.version}</version>
+                       </dependency>
+                       <dependency>
+                               <groupId>org.commonmark</groupId>
+                               <artifactId>commonmark-ext-heading-anchor</artifactId>
+                               <version>${commonmark.version}</version>
+                       </dependency>
+                       <!-- dependency> <groupId>net.jthink</groupId>
+                       <artifactId>jaudiotagger</artifactId> 
+                               <version>2.2.6</version> </dependency -->
+
+                       <!-- ── Konfiguration ─────────────────────────────── -->
+                       <dependency>
+                               <groupId>org.yaml</groupId>
+                               <artifactId>snakeyaml</artifactId>
+                               <version>${snakeyaml.version}</version>
+                       </dependency>
+               </dependencies>
+       </dependencyManagement>
+
+       <dependencies>
+
+               <!-- ── Datenbank ─────────────────────────────────── -->
+               <dependency>
+                       <groupId>org.jdbi</groupId>
+                       <artifactId>jdbi3-core</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>org.jdbi</groupId>
+                       <artifactId>jdbi3-sqlobject</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>com.zaxxer</groupId>
+                       <artifactId>HikariCP</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>org.postgresql</groupId>
+                       <artifactId>postgresql</artifactId>
+               </dependency>
+
+               <!-- ── Templating ────────────────────────────────── -->
+               <dependency>
+                       <groupId>org.freemarker</groupId>
+                       <artifactId>freemarker</artifactId>
+               </dependency>
+
+               <!-- ── Markdown ──────────────────────────────────── -->
+               <dependency>
+                       <groupId>org.commonmark</groupId>
+                       <artifactId>commonmark</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>org.commonmark</groupId>
+                       <artifactId>commonmark-ext-gfm-tables</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>org.commonmark</groupId>
+                       <artifactId>commonmark-ext-heading-anchor</artifactId>
+               </dependency>
+
+               <!-- ── Konfiguration ─────────────────────────────── -->
+               <dependency>
+                       <groupId>org.yaml</groupId>
+                       <artifactId>snakeyaml</artifactId>
+               </dependency>
+
+               <!-- slf4j + logback: bereits vom Parent (laktatnebelproductmaster) -->
+
+               <dependency>
+                       <groupId>commons-lang</groupId>
+                       <artifactId>commons-lang</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>de.laktatnebel.libs</groupId>
+                       <artifactId>filelib</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>de.laktatnebel.libs</groupId>
+                       <artifactId>dblib</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>de.laktatnebel.libs</groupId>
+                       <artifactId>propertylib</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>commons-io</groupId>
+                       <artifactId>commons-io</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>commons-codec</groupId>
+                       <artifactId>commons-codec</artifactId>
+               </dependency>
+               <!-- dependency> <groupId>net.jthink</groupId>
+               <artifactId>jaudiotagger</artifactId> 
+                       </dependency -->
+       </dependencies>
 </project>
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/BuildResult.java b/src/main/java/de/laktatnebel/product/websitegenerator/BuildResult.java
new file mode 100644 (file)
index 0000000..76db512
--- /dev/null
@@ -0,0 +1,11 @@
+package de.laktatnebel.product.websitegenerator;
+
+/**
+ * Ergebnis eines Build-Durchlaufs.
+ */
+public record BuildResult(int pagesBuilt, int errorCount) {
+
+    public boolean hasErrors() {
+        return errorCount > 0;
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/Main.java b/src/main/java/de/laktatnebel/product/websitegenerator/Main.java
new file mode 100644 (file)
index 0000000..38f17e5
--- /dev/null
@@ -0,0 +1,68 @@
+package de.laktatnebel.product.websitegenerator;
+
+import de.laktatnebel.product.websitegenerator.config.SiteConfig;
+import de.laktatnebel.product.websitegenerator.generator.SiteGenerator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Path;
+
+/**
+ * Einstiegspunkt des Static Site Generators.
+ *
+ * Aufruf:
+ *   java -jar ssg.jar                        → config.yaml im aktuellen Verzeichnis
+ *   java -jar ssg.jar /pfad/zur/config.yaml  → explizite Konfigurationsdatei
+ *   java -jar ssg.jar --dry-run              → ohne Dateien zu schreiben
+ */
+public class Main {
+
+    private static final Logger log = LoggerFactory.getLogger(Main.class);
+
+    public static void main(String[] args) {
+        log.info("=== Laktatnebel SSG ===");
+
+        // ── Argumente parsen ──────────────────────────────────────────
+        Path configPath = Path.of("config.yaml");
+        boolean dryRun  = false;
+
+        for (String arg : args) {
+            if ("--dry-run".equals(arg)) {
+                dryRun = true;
+                log.info("Modus: dry-run (keine Dateien werden geschrieben)");
+            } else {
+                configPath = Path.of(arg);
+            }
+        }
+
+        // ── Konfiguration laden ───────────────────────────────────────
+        SiteConfig config;
+        try {
+            config = SiteConfig.load(configPath);
+            log.info("Konfiguration geladen: {}", configPath);
+        } catch (Exception e) {
+            log.error("Fehler beim Laden der Konfiguration: {}", e.getMessage());
+            System.exit(1);
+            return;
+        }
+
+        // ── Build starten ─────────────────────────────────────────────
+        long start = System.currentTimeMillis();
+        try {
+            SiteGenerator generator = new SiteGenerator(config, dryRun);
+            BuildResult result = generator.build();
+
+            long elapsed = System.currentTimeMillis() - start;
+            log.info("Build abgeschlossen: {} Seiten in {}ms generiert", result.pagesBuilt(), elapsed);
+
+            if (result.hasErrors()) {
+                log.warn("{} Fehler aufgetreten – siehe Log", result.errorCount());
+                System.exit(2);
+            }
+
+        } catch (Exception e) {
+            log.error("Build fehlgeschlagen: {}", e.getMessage(), e);
+            System.exit(1);
+        }
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java
new file mode 100644 (file)
index 0000000..e5e4e2a
--- /dev/null
@@ -0,0 +1,100 @@
+package de.laktatnebel.product.websitegenerator.config;
+
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+/**
+ * Lädt und hält die Konfiguration aus config.yaml.
+ * Kein Framework, kein Magic – einfaches Map-Parsing.
+ */
+public class SiteConfig {
+
+    // Site
+    public final String siteName;
+    public final String baseUrl;
+    public final String language;
+    public final String copyright;
+
+    // Database
+    public final String dbHost;
+    public final int    dbPort;
+    public final String dbName;
+    public final String dbUser;
+    public final String dbPassword;
+
+    // Paths
+    public final Path outputPath;
+    public final Path mediaOriginalsPath;
+    public final Path mediaOutputPath;
+    public final String templatesPath;
+
+    // Build
+    public final boolean cleanOutput;
+    public final int     buildThreads;
+    public final int     imageLg;
+    public final int     imageMd;
+    public final int     imageSm;
+    public final String  imageFormat;
+    public final int     imageQuality;
+
+    @SuppressWarnings("unchecked")
+    private SiteConfig(Map<String, Object> raw) {
+        Map<String, Object> site  = (Map<String, Object>) raw.get("site");
+        Map<String, Object> db    = (Map<String, Object>) raw.get("database");
+        Map<String, Object> paths = (Map<String, Object>) raw.get("paths");
+        Map<String, Object> build = (Map<String, Object>) raw.get("build");
+        Map<String, Object> imgs  = (Map<String, Object>) build.get("image_variants");
+
+        // Site
+        siteName  = (String) site.get("name");
+        baseUrl   = (String) site.get("base_url");
+        language  = (String) site.get("language");
+        copyright = (String) site.get("copyright");
+
+        // DB
+        dbHost     = (String)  db.get("host");
+        dbPort     = (Integer) db.get("port");
+        dbName     = (String)  db.get("name");
+        dbUser     = (String)  db.get("user");
+        dbPassword = (String)  db.get("password");
+
+        // Paths
+        outputPath        = Path.of((String) paths.get("output"));
+        mediaOriginalsPath = Path.of((String) paths.get("media_originals"));
+        mediaOutputPath   = Path.of((String) paths.get("media_output"));
+        templatesPath     = (String) paths.get("templates");
+
+        // Build
+        cleanOutput  = (Boolean) build.getOrDefault("clean_output", false);
+        buildThreads = (Integer) build.getOrDefault("threads", 1);
+        imageLg      = (Integer) imgs.get("lg");
+        imageMd      = (Integer) imgs.get("md");
+        imageSm      = (Integer) imgs.get("sm");
+        imageFormat  = (String)  build.getOrDefault("image_format", "webp");
+        imageQuality = (Integer) build.getOrDefault("image_quality", 82);
+    }
+
+    /**
+     * Lädt die Konfiguration aus einer YAML-Datei.
+     */
+    public static SiteConfig load(Path configFile) throws IOException {
+        if (!Files.exists(configFile)) {
+            throw new IOException("Konfigurationsdatei nicht gefunden: " + configFile.toAbsolutePath());
+        }
+        try (InputStream in = Files.newInputStream(configFile)) {
+            Yaml yaml = new Yaml();
+            Map<String, Object> raw = yaml.load(in);
+            return new SiteConfig(raw);
+        }
+    }
+
+    /** JDBC-URL für PostgreSQL */
+    public String jdbcUrl() {
+        return "jdbc:postgresql://" + dbHost + ":" + dbPort + "/" + dbName;
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java b/src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java
new file mode 100644 (file)
index 0000000..38f9011
--- /dev/null
@@ -0,0 +1,71 @@
+package de.laktatnebel.product.websitegenerator.db;
+
+import de.laktatnebel.product.websitegenerator.config.SiteConfig;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.jdbi.v3.core.Jdbi;
+import org.jdbi.v3.sqlobject.SqlObjectPlugin;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Closeable;
+
+/**
+ * Verwaltet den Datenbankzugriff via JDBI3.
+ * Verwendet HikariCP als Connection Pool.
+ *
+ * Hinweis: Für einen SSG (einmaliger Lauf) reicht auch ein
+ * einfacher DriverManager – HikariCP lohnt sich wenn wir
+ * parallele Build-Threads einsetzen (config: build.threads > 1).
+ */
+public class Database implements Closeable {
+
+    private static final Logger log = LoggerFactory.getLogger(Database.class);
+
+    private final HikariDataSource dataSource;
+    private final Jdbi jdbi;
+
+    public Database(SiteConfig config) {
+        log.info("Datenbankverbindung aufbauen: {}", config.jdbcUrl());
+
+        HikariConfig hc = new HikariConfig();
+        hc.setJdbcUrl(config.jdbcUrl());
+        hc.setUsername(config.dbUser);
+        hc.setPassword(config.dbPassword);
+        hc.setMaximumPoolSize(config.buildThreads + 1);
+        hc.setMinimumIdle(1);
+        hc.setConnectionTimeout(10_000);
+        hc.setPoolName("ssg-pool");
+
+        // PostgreSQL-spezifische Optimierungen
+        hc.addDataSourceProperty("reWriteBatchedInserts", "true");
+        hc.addDataSourceProperty("ApplicationName",       "tricoach-ssg");
+
+        this.dataSource = new HikariDataSource(hc);
+        this.jdbi = Jdbi.create(dataSource)
+                .installPlugin(new SqlObjectPlugin());
+
+        log.info("Datenbankverbindung OK");
+    }
+
+    public Jdbi jdbi() {
+        return jdbi;
+    }
+
+    /** Convenience: Query direkt ausführen */
+    public <T> T withHandle(org.jdbi.v3.core.HandleCallback<T, Exception> callback) {
+        try {
+            return jdbi.withHandle(callback);
+        } catch (Exception e) {
+            throw new RuntimeException("DB-Fehler: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public void close() {
+        if (dataSource != null && !dataSource.isClosed()) {
+            dataSource.close();
+            log.debug("Datenbankverbindung geschlossen");
+        }
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java b/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java
new file mode 100644 (file)
index 0000000..11f3a2c
--- /dev/null
@@ -0,0 +1,233 @@
+package de.laktatnebel.product.websitegenerator.db;
+
+import de.laktatnebel.product.websitegenerator.model.Models.*;
+import org.jdbi.v3.core.mapper.RowMapper;
+import org.jdbi.v3.core.statement.StatementContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+/**
+ * Alle Datenbankabfragen des Generators – gesammelt an einem Ort.
+ * Verwendet JDBI's fluent API direkt (kein @SqlObject Interface nötig
+ * für diese Größenordnung).
+ */
+public class PageRepository {
+
+    private static final Logger log = LoggerFactory.getLogger(PageRepository.class);
+
+    private final Database db;
+
+    public PageRepository(Database db) {
+        this.db = db;
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Seiten
+    // ────────────────────────────────────────────────────────────────
+
+    /**
+     * Alle zu publizierenden Seiten laden (nutzt den View v_published_content).
+     */
+    public List<PageView> findAllPublished() {
+        log.debug("Lade alle publizierten Seiten...");
+        return db.withHandle(handle ->
+            handle.createQuery("SELECT * FROM v_published_content ORDER BY slug")
+                  .map(new PageViewMapper())
+                  .list()
+        );
+    }
+
+    /**
+     * Seiten die jetzt geplant fällig sind (für Cronjob-Modus).
+     */
+    public List<PageView> findDueScheduled() {
+        return db.withHandle(handle ->
+            handle.createQuery("""
+                SELECT * FROM v_published_content
+                WHERE status = 'scheduled'
+                  AND publish_at <= NOW()
+                ORDER BY slug
+                """)
+                  .map(new PageViewMapper())
+                  .list()
+        );
+    }
+
+    /**
+     * Eine einzelne Seite per Slug (für inkrementellen Build).
+     */
+    public PageView findBySlug(String slug) {
+        return db.withHandle(handle ->
+            handle.createQuery("SELECT * FROM v_published_content WHERE slug = :slug")
+                  .bind("slug", slug)
+                  .map(new PageViewMapper())
+                  .findOne()
+                  .orElse(null)
+        );
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Navigation
+    // ────────────────────────────────────────────────────────────────
+
+    /**
+     * Navigation komplett laden (flat, Baumaufbau im Generator).
+     */
+    public List<NavItem> findNavFlat() {
+        return db.withHandle(handle ->
+            handle.createQuery("SELECT * FROM v_nav_tree ORDER BY parent_id NULLS FIRST, sort_order")
+                  .map(new NavItemFlatMapper())
+                  .list()
+        );
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Media (für Image-Processor)
+    // ────────────────────────────────────────────────────────────────
+
+    /**
+     * Alle Bilder die noch keine WebP-Varianten haben.
+     */
+    public List<Media> findUnprocessedMedia() {
+        return db.withHandle(handle ->
+            handle.createQuery("""
+                SELECT * FROM media
+                WHERE media_type = 'image'
+                  AND path_webp_lg IS NULL
+                ORDER BY created_at
+                """)
+                  .map(new MediaMapper())
+                  .list()
+        );
+    }
+
+    /**
+     * WebP-Pfade nach Verarbeitung zurückschreiben.
+     */
+    public void updateMediaPaths(int mediaId, String lg, String md, String sm) {
+        db.withHandle(handle -> {
+            handle.createUpdate("""
+                UPDATE media SET
+                    path_webp_lg = :lg,
+                    path_webp_md = :md,
+                    path_webp_sm = :sm
+                WHERE id = :id
+                """)
+                  .bind("id", mediaId)
+                  .bind("lg", lg)
+                  .bind("md", md)
+                  .bind("sm", sm)
+                  .execute();
+            return null;
+        });
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Build-Log
+    // ────────────────────────────────────────────────────────────────
+
+    public int insertBuildLog(String triggeredBy) {
+        return db.withHandle(handle ->
+            handle.createUpdate("""
+                INSERT INTO build_log (triggered_by, started_at, status)
+                VALUES (:by, NOW(), 'running')
+                """)
+                  .bind("by", triggeredBy)
+                  .executeAndReturnGeneratedKeys("id")
+                  .mapTo(Integer.class)
+                  .one()
+        );
+    }
+
+    public void finishBuildLog(int buildId, int pagesBuilt, String status, String errorMsg) {
+        db.withHandle(handle -> {
+            handle.createUpdate("""
+                UPDATE build_log SET
+                    finished_at  = NOW(),
+                    pages_built  = :pages,
+                    status       = :status,
+                    error_message = :err
+                WHERE id = :id
+                """)
+                  .bind("id",     buildId)
+                  .bind("pages",  pagesBuilt)
+                  .bind("status", status)
+                  .bind("err",    errorMsg)
+                  .execute();
+            return null;
+        });
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Row Mappers
+    // ────────────────────────────────────────────────────────────────
+
+    static class PageViewMapper implements RowMapper<PageView> {
+        @Override
+        public PageView map(ResultSet rs, StatementContext ctx) throws SQLException {
+            return new PageView(
+                rs.getInt("page_id"),
+                rs.getString("slug"),
+                rs.getString("template"),
+                rs.getInt("content_id"),
+                rs.getString("title"),
+                rs.getString("subtitle"),
+                rs.getString("section_label"),
+                rs.getString("excerpt"),
+                rs.getString("body"),
+                rs.getString("meta_title"),
+                rs.getString("meta_desc"),
+                rs.getString("canonical_url"),
+                rs.getObject("publish_at",   OffsetDateTime.class),
+                rs.getObject("unpublish_at", OffsetDateTime.class),
+                rs.getString("author_name"),
+                rs.getString("author_slug"),
+                rs.getString("hero_media_path"),
+                rs.getString("hero_alt_text"),
+                rs.getString("card_media_path"),
+                rs.getString("card_alt_text"),
+                rs.getString("tag_slugs"),
+                rs.getString("tag_labels")
+            );
+        }
+    }
+
+    static class NavItemFlatMapper implements RowMapper<NavItem> {
+        @Override
+        public NavItem map(ResultSet rs, StatementContext ctx) throws SQLException {
+            // parent_id kann NULL sein (Top-Level) → getObject liefert null statt 0
+            Integer parentId = (Integer) rs.getObject("parent_id");
+            return new NavItem(
+                rs.getInt("id"),
+                parentId,
+                rs.getString("label"),
+                rs.getString("url"),
+                rs.getBoolean("open_new_tab"),
+                new java.util.ArrayList<>()   // Kinder werden im SiteGenerator befüllt
+            );
+        }
+    }
+
+    static class MediaMapper implements RowMapper<Media> {
+        @Override
+        public Media map(ResultSet rs, StatementContext ctx) throws SQLException {
+            return new Media(
+                rs.getInt("id"),
+                rs.getString("path_original"),
+                rs.getString("path_webp_lg"),
+                rs.getString("path_webp_md"),
+                rs.getString("path_webp_sm"),
+                rs.getString("alt_text"),
+                rs.getString("caption"),
+                rs.getInt("width_px"),
+                rs.getInt("height_px"),
+                rs.getString("sha256_hash")
+            );
+        }
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java b/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java
new file mode 100644 (file)
index 0000000..946b09d
--- /dev/null
@@ -0,0 +1,176 @@
+package de.laktatnebel.product.websitegenerator.generator;
+
+import de.laktatnebel.product.websitegenerator.BuildResult;
+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.NavItem;
+import de.laktatnebel.product.websitegenerator.model.Models.PageView;
+import de.laktatnebel.product.websitegenerator.renderer.FreemarkerRenderer;
+import de.laktatnebel.product.websitegenerator.renderer.MarkdownRenderer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Orchestriert den gesamten Build-Prozess:
+ *  1. DB lesen
+ *  2. Navigation aufbauen (flat → Baum)
+ *  3. Markdown → HTML rendern
+ *  4. Freemarker-Template befüllen
+ *  5. HTML-Datei schreiben
+ */
+public class SiteGenerator {
+
+    private static final Logger log = LoggerFactory.getLogger(SiteGenerator.class);
+
+    private final SiteConfig config;
+    private final boolean    dryRun;
+
+    public SiteGenerator(SiteConfig config, boolean dryRun) {
+        this.config = config;
+        this.dryRun = dryRun;
+    }
+
+    public BuildResult build() throws Exception {
+        AtomicInteger built  = new AtomicInteger(0);
+        AtomicInteger errors = new AtomicInteger(0);
+
+        try (Database db = new Database(config)) {
+
+            PageRepository     repo = new PageRepository(db);
+            MarkdownRenderer   md   = new MarkdownRenderer();
+            FreemarkerRenderer ftl  = new FreemarkerRenderer(config);
+
+            // ── 1. Build-Log starten ──────────────────────────────
+            int buildLogId = repo.insertBuildLog("cli");
+
+            try {
+                // ── 2. Navigation laden & Baum aufbauen ──────────
+                List<NavItem> navFlat = repo.findNavFlat();
+                List<NavItem> navTree = buildNavTree(navFlat);
+                log.info("Navigation: {} Top-Level-Einträge", navTree.size());
+
+                // ── 3. Alle publizierten Seiten laden ─────────────
+                List<PageView> pages = repo.findAllPublished();
+                log.info("{} Seiten gefunden", pages.size());
+
+                // ── 4. Seiten generieren ───────────────────────────
+                for (PageView page : pages) {
+                    try {
+                        generatePage(page, navTree, md, ftl);
+                        built.incrementAndGet();
+                    } catch (Exception e) {
+                        log.error("Fehler bei Seite '{}': {}", page.slug(), e.getMessage(), e);
+                        errors.incrementAndGet();
+                    }
+                }
+
+                // ── 5. Build-Log abschließen ───────────────────────
+                String status = errors.get() > 0 ? "partial" : "success";
+                repo.finishBuildLog(buildLogId, built.get(), status, null);
+
+            } catch (Exception e) {
+                repo.finishBuildLog(buildLogId, built.get(), "error", e.getMessage());
+                throw e;
+            }
+        }
+
+        return new BuildResult(built.get(), errors.get());
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Einzelne Seite generieren
+    // ────────────────────────────────────────────────────────────────
+
+    private void generatePage(PageView page, List<NavItem> navTree,
+                               MarkdownRenderer md, FreemarkerRenderer ftl) throws Exception {
+
+        log.debug("Generiere: {} [{}]", page.slug(), page.template());
+
+        String bodyHtml = md.render(page.body() != null ? page.body() : "");
+
+        Map<String, Object> model = new HashMap<>();
+        model.put("page",     page);
+        model.put("bodyHtml", bodyHtml);
+        model.put("nav",      navTree);
+        model.put("site", Map.of(
+            "name",      config.siteName,
+            "baseUrl",   config.baseUrl,
+            "language",  config.language,
+            "copyright", config.copyright
+        ));
+
+        String html = ftl.render(page.template() + ".ftlh", model);
+
+        Path outputFile = resolveOutputPath(page.slug());
+        if (!dryRun) {
+            Files.createDirectories(outputFile.getParent());
+            Files.writeString(outputFile, html, StandardCharsets.UTF_8);
+            log.debug("Geschrieben: {}", outputFile);
+        } else {
+            log.info("[dry-run] Würde schreiben: {}", outputFile);
+        }
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Output-Pfad berechnen
+    // ────────────────────────────────────────────────────────────────
+
+    private Path resolveOutputPath(String slug) {
+        if ("index".equals(slug)) {
+            return config.outputPath.resolve("index.html");
+        }
+        return config.outputPath.resolve(slug).resolve("index.html");
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // Navigation: Flat-Liste → Baum
+    // Funktioniert weil v_nav_tree ORDER BY parent_id NULLS FIRST liefert:
+    // alle Eltern kommen vor ihren Kindern.
+    // ────────────────────────────────────────────────────────────────
+
+    private List<NavItem> buildNavTree(List<NavItem> flat) {
+        // id → mutable Kinderliste
+        Map<Integer, List<NavItem>> childrenMap = new LinkedHashMap<>();
+        for (NavItem item : flat) {
+            childrenMap.put(item.id(), new ArrayList<>());
+        }
+
+        List<NavItem> roots = new ArrayList<>();
+
+        for (NavItem item : flat) {
+            if (item.parentId() == null) {
+                roots.add(item);
+            } else {
+                List<NavItem> siblings = childrenMap.get(item.parentId());
+                if (siblings != null) {
+                    siblings.add(item);
+                } else {
+                    log.warn("Nav-Item {} hat unbekannte parentId {}", item.id(), item.parentId());
+                }
+            }
+        }
+
+        // Kinder in die Records einbauen (Records sind immutable → neu erzeugen)
+        return enrichChildren(roots, childrenMap);
+    }
+
+    private List<NavItem> enrichChildren(List<NavItem> items,
+                                          Map<Integer, List<NavItem>> childrenMap) {
+        List<NavItem> result = new ArrayList<>();
+        for (NavItem item : items) {
+            List<NavItem> kids = enrichChildren(
+                childrenMap.getOrDefault(item.id(), List.of()), childrenMap);
+            result.add(new NavItem(
+                item.id(), item.parentId(), item.label(),
+                item.url(), item.openNewTab(), kids));
+        }
+        return result;
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java b/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java
new file mode 100644 (file)
index 0000000..76b984e
--- /dev/null
@@ -0,0 +1,77 @@
+package de.laktatnebel.product.websitegenerator.model;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+
+/**
+ * Domain-Modelle als Java Records (immutable, kein Boilerplate).
+ * Alle Records sind als statische innere Records der Klasse Models definiert,
+ * damit der Import "import com.tricoach.ssg.model.Models.*;" funktioniert.
+ */
+public class Models {
+
+    public record PageView(
+        int            pageId,
+        String         slug,
+        String         template,
+        int            contentId,
+        String         title,
+        String         subtitle,
+        String         sectionLabel,
+        String         excerpt,
+        String         body,
+        String         metaTitle,
+        String         metaDesc,
+        String         canonicalUrl,
+        OffsetDateTime publishAt,
+        OffsetDateTime unpublishAt,
+        String         authorName,
+        String         authorSlug,
+        String         heroMediaPath,
+        String         heroAltText,
+        String         cardMediaPath,
+        String         cardAltText,
+        String         tagSlugs,
+        String         tagLabels
+    ) {
+        public List<Tag> tags() {
+            if (tagSlugs == null || tagSlugs.isBlank()) return List.of();
+            String[] slugs  = tagSlugs.split(",");
+            String[] labels = tagLabels != null ? tagLabels.split(",") : slugs;
+            Tag[]    result = new Tag[slugs.length];
+            for (int i = 0; i < slugs.length; i++) {
+                result[i] = new Tag(0,
+                    slugs[i].trim(),
+                    i < labels.length ? labels[i].trim() : slugs[i].trim());
+            }
+            return List.of(result);
+        }
+    }
+
+    /** parentId == null → Top-Level-Eintrag */
+    public record NavItem(
+        int           id,
+        Integer       parentId,
+        String        label,
+        String        url,
+        boolean       openNewTab,
+        List<NavItem> children
+    ) {}
+
+    public record Tag(int id, String slug, String label) {}
+
+    public record Author(int id, String slug, String name, String bio, String avatarPath) {}
+
+    public record Media(
+        int    id,
+        String pathOriginal,
+        String pathWebpLg,
+        String pathWebpMd,
+        String pathWebpSm,
+        String altText,
+        String caption,
+        int    widthPx,
+        int    heightPx,
+        String sha256Hash
+    ) {}
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java b/src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java
new file mode 100644 (file)
index 0000000..89bafa4
--- /dev/null
@@ -0,0 +1,63 @@
+package de.laktatnebel.product.websitegenerator.renderer;
+
+import de.laktatnebel.product.websitegenerator.config.SiteConfig;
+import freemarker.template.Configuration;
+import freemarker.template.Template;
+import freemarker.template.TemplateExceptionHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+/**
+ * Rendert Freemarker-Templates zu HTML-Strings.
+ *
+ * Templates werden aus dem Filesystem geladen (nicht aus dem JAR),
+ * damit sie ohne Neubau des JARs angepasst werden können.
+ *
+ * Fallback: Falls kein externes Template-Verzeichnis existiert,
+ * werden Templates aus dem JAR-Classpath geladen.
+ */
+public class FreemarkerRenderer {
+
+    private static final Logger log = LoggerFactory.getLogger(FreemarkerRenderer.class);
+
+    private final Configuration cfg;
+
+    public FreemarkerRenderer(SiteConfig config) throws Exception {
+        cfg = new Configuration(Configuration.VERSION_2_3_33);
+        cfg.setDefaultEncoding(StandardCharsets.UTF_8.name());
+        cfg.setOutputEncoding(StandardCharsets.UTF_8.name());
+        cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
+        cfg.setLogTemplateExceptions(false);
+        cfg.setWrapUncheckedExceptions(true);
+        cfg.setFallbackOnNullLoopVariable(false);
+
+        // Templates aus Filesystem (bevorzugt) oder Classpath
+        Path templateDir = Path.of(config.templatesPath);
+        if (Files.isDirectory(templateDir)) {
+            cfg.setDirectoryForTemplateLoading(templateDir.toFile());
+            log.info("Templates aus Filesystem: {}", templateDir.toAbsolutePath());
+        } else {
+            cfg.setClassForTemplateLoading(FreemarkerRenderer.class, "/templates");
+            log.info("Templates aus Classpath: /templates");
+        }
+    }
+
+    /**
+     * Template rendern und als String zurückgeben.
+     *
+     * @param templateName  Dateiname, z.B. "article.ftlh"
+     * @param model         Template-Variablen
+     */
+    public String render(String templateName, Map<String, Object> model) throws Exception {
+        Template template = cfg.getTemplate(templateName);
+        StringWriter out  = new StringWriter(8192);
+        template.process(model, out);
+        return out.toString();
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/renderer/MarkdownRenderer.java b/src/main/java/de/laktatnebel/product/websitegenerator/renderer/MarkdownRenderer.java
new file mode 100644 (file)
index 0000000..da884cb
--- /dev/null
@@ -0,0 +1,46 @@
+package de.laktatnebel.product.websitegenerator.renderer;
+
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.ext.heading.anchor.HeadingAnchorExtension;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+
+import java.util.List;
+
+/**
+ * Konvertiert Markdown-Text zu HTML.
+ * CommonMark-konform + GFM Tables + Heading Anchors.
+ *
+ * Thread-safe: Parser und Renderer sind immutable nach der Initialisierung.
+ */
+public class MarkdownRenderer {
+
+    private final Parser       parser;
+    private final HtmlRenderer renderer;
+
+    public MarkdownRenderer() {
+        var extensions = List.of(
+            TablesExtension.create(),
+            HeadingAnchorExtension.create()
+        );
+
+        this.parser = Parser.builder()
+                            .extensions(extensions)
+                            .build();
+
+        this.renderer = HtmlRenderer.builder()
+                                    .extensions(extensions)
+                                    .escapeHtml(false)   // wir vertrauen dem eigenen Content
+                                    .build();
+    }
+
+    /**
+     * Markdown → HTML.
+     * Leerer Input gibt leeren String zurück.
+     */
+    public String render(String markdown) {
+        if (markdown == null || markdown.isBlank()) return "";
+        var document = parser.parse(markdown);
+        return renderer.render(document);
+    }
+}
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml
new file mode 100644 (file)
index 0000000..f271648
--- /dev/null
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+
+    <!-- ── Konsole ──────────────────────────────────────────────── -->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{HH:mm:ss} %-5level [%logger{36}] %msg%n</pattern>
+        </encoder>
+    </appender>
+
+    <!-- ── Datei (rolling, 30 Tage) ─────────────────────────────── -->
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${ssg.logdir:-/var/log/ssg}/build.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${ssg.logdir:-/var/log/ssg}/build.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <maxHistory>30</maxHistory>
+            <totalSizeCap>100MB</totalSizeCap>
+        </rollingPolicy>
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level [%logger{36}] %msg%n</pattern>
+        </encoder>
+    </appender>
+
+    <!-- ── Logger ────────────────────────────────────────────────── -->
+
+    <!-- DB-Queries: INFO reicht, DEBUG nur zum Debuggen -->
+    <logger name="de.laktatnebel.tricoach.ssg.db"        level="INFO"/>
+    <logger name="de.laktatnebel.tricoach.ssg.generator"  level="INFO"/>
+    <logger name="de.laktatnebel.tricoach.ssg.renderer"   level="INFO"/>
+
+    <!-- HikariCP: nur Warnungen, sonst zu geschwätzig -->
+    <logger name="com.zaxxer.hikari" level="WARN"/>
+
+    <!-- Freemarker: nur Fehler -->
+    <logger name="freemarker" level="ERROR"/>
+
+    <!-- Root -->
+    <root level="INFO">
+        <appender-ref ref="CONSOLE"/>
+        <appender-ref ref="FILE"/>
+    </root>
+
+</configuration>