From: Ole B. Rosentreter Date: Fri, 13 Mar 2026 22:06:35 +0000 (+0100) Subject: Java-Files aus AI eingebaut X-Git-Url: https://git.laktatnebel.de/?a=commitdiff_plain;h=664d17aa6da97806d5f98b1b2399a2da7d07222d;p=websitegenerator.git Java-Files aus AI eingebaut --- diff --git a/pom.xml b/pom.xml index d301552..69e8539 100644 --- a/pom.xml +++ b/pom.xml @@ -1,17 +1,203 @@ - 4.0.0 de.laktatnebel.product websitegenerator 0.0.1-SNAPSHOT - pom - - websitegenerator + jar de.laktatnebel.maven - laktatnebelproductmaster - 2.1.9 + laktatnebelproductstandalone-plain + 3.0.0 + laktatnebel.de Static Site Generator + + + 2.1.0 + 2.1.0 + 2.1.0 + 2.6 + + ${project.groupId}.${project.artifactId}.Main + ${project.artifactId} + + + 3.45.1 + 5.1.0 + 42.7.3 + 2.3.33 + 0.25.0 + 2.2 + + + + + + commons-lang + commons-lang + ${commons-lang.version} + + + de.laktatnebel.libs + filelib + ${filelib.version} + + + de.laktatnebel.libs + dblib + ${dblib.version} + + + de.laktatnebel.libs + propertylib + ${propertylib.version} + + + commons-io + commons-io + 1.3.2 + + + commons-codec + commons-codec + 1.10 + + + + + org.jdbi + jdbi3-core + ${jdbi.version} + + + org.jdbi + jdbi3-sqlobject + ${jdbi.version} + + + com.zaxxer + HikariCP + ${hikari.version} + + + org.postgresql + postgresql + ${postgresql.version} + + + + + org.freemarker + freemarker + ${freemarker.version} + + + + + org.commonmark + commonmark + ${commonmark.version} + + + org.commonmark + commonmark-ext-gfm-tables + ${commonmark.version} + + + org.commonmark + commonmark-ext-heading-anchor + ${commonmark.version} + + + + + + org.yaml + snakeyaml + ${snakeyaml.version} + + + + + + + + + org.jdbi + jdbi3-core + + + org.jdbi + jdbi3-sqlobject + + + com.zaxxer + HikariCP + + + org.postgresql + postgresql + + + + + org.freemarker + freemarker + + + + + org.commonmark + commonmark + + + org.commonmark + commonmark-ext-gfm-tables + + + org.commonmark + commonmark-ext-heading-anchor + + + + + org.yaml + snakeyaml + + + + + + commons-lang + commons-lang + + + de.laktatnebel.libs + filelib + + + de.laktatnebel.libs + dblib + + + de.laktatnebel.libs + propertylib + + + commons-io + commons-io + + + commons-codec + commons-codec + + + 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 index 0000000..76db512 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/BuildResult.java @@ -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 index 0000000..38f17e5 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/Main.java @@ -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 index 0000000..e5e4e2a --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java @@ -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 raw) { + Map site = (Map) raw.get("site"); + Map db = (Map) raw.get("database"); + Map paths = (Map) raw.get("paths"); + Map build = (Map) raw.get("build"); + Map imgs = (Map) 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 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 index 0000000..38f9011 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/db/Database.java @@ -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 withHandle(org.jdbi.v3.core.HandleCallback 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 index 0000000..11f3a2c --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/db/PageRepository.java @@ -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 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 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 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 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 { + @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 { + @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 { + @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 index 0000000..946b09d --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/generator/SiteGenerator.java @@ -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 navFlat = repo.findNavFlat(); + List navTree = buildNavTree(navFlat); + log.info("Navigation: {} Top-Level-Einträge", navTree.size()); + + // ── 3. Alle publizierten Seiten laden ───────────── + List 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 navTree, + MarkdownRenderer md, FreemarkerRenderer ftl) throws Exception { + + log.debug("Generiere: {} [{}]", page.slug(), page.template()); + + String bodyHtml = md.render(page.body() != null ? page.body() : ""); + + Map 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 buildNavTree(List flat) { + // id → mutable Kinderliste + Map> childrenMap = new LinkedHashMap<>(); + for (NavItem item : flat) { + childrenMap.put(item.id(), new ArrayList<>()); + } + + List roots = new ArrayList<>(); + + for (NavItem item : flat) { + if (item.parentId() == null) { + roots.add(item); + } else { + List 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 enrichChildren(List items, + Map> childrenMap) { + List result = new ArrayList<>(); + for (NavItem item : items) { + List 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 index 0000000..76b984e --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/model/Models.java @@ -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 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 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 index 0000000..89bafa4 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/renderer/FreemarkerRenderer.java @@ -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 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 index 0000000..da884cb --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/renderer/MarkdownRenderer.java @@ -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 index 0000000..f271648 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,43 @@ + + + + + + + %d{HH:mm:ss} %-5level [%logger{36}] %msg%n + + + + + + ${ssg.logdir:-/var/log/ssg}/build.log + + ${ssg.logdir:-/var/log/ssg}/build.%d{yyyy-MM-dd}.log + 30 + 100MB + + + %d{yyyy-MM-dd HH:mm:ss} %-5level [%logger{36}] %msg%n + + + + + + + + + + + + + + + + + + + + + + +