-<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>
--- /dev/null
+package de.laktatnebel.product.websitegenerator;
+
+/**
+ * Ergebnis eines Build-Durchlaufs.
+ */
+public record BuildResult(int pagesBuilt, int errorCount) {
+
+ public boolean hasErrors() {
+ return errorCount > 0;
+ }
+}
--- /dev/null
+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);
+ }
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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");
+ }
+ }
+}
--- /dev/null
+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")
+ );
+ }
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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
+ ) {}
+}
--- /dev/null
+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();
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+<?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>