4. Test gegen echtes Schema (db/schema.sql) mit lokaler PostgreSQL:
generiertes SQL muss fehlerfrei durchlaufen und korrekt verknüpfen.
5. Lint-Ausgabe vervollständigen.
+
+---
+
+# SPEC (Phase 2): Social-Asset-Generator (Instagram via Buffer)
+
+Status: Design abgeschlossen, Implementierung offen.
+Reihenfolge: NACH dem Markdown-Loader. Baut auf der content/blog/<slug>/-
+Struktur auf – sobald Artikel mit Hero + Überschriften vorliegen, ist
+dies nur ein weiterer Output-Schritt über denselben Daten.
+
+## Zweck
+
+Das manuelle Zusammenschieben von Instagram-Posts am Handy entfällt.
+Der Build-Lauf erzeugt fertige Bild-Assets + Caption-Text pro Artikel.
+Der Redakteur lädt diese am Rechner in Buffer.com hoch – KEINE direkte
+Instagram-Integration, keine API, kein OAuth. Buffer übernimmt Timing
+und Posting. Lokal-first: Dateien raus, Mensch behält Kontrolle.
+
+WICHTIG: Speichern ≠ Posten (anders als bei der Website). Das Posten
+bleibt bewusst manuell, weil Social Timing und Kontext braucht.
+
+## Scope Phase 2
+
+Nur KARUSSELL-Posts. Das ist der Sweet Spot:
+- Artikelstruktur (Hero + ## Überschriften) = Karussellstruktur, fast
+ vollständig automatisierbar.
+- Story: später, teilautomatisierbar, dünner Mehrwert.
+- Reel: NICHT automatisieren. Statisches Hero + Text als Pseudo-Video
+ wird als Bot-Content erkannt und vom Algorithmus bestraft. Reels
+ brauchen echtes Bewegtbild.
+
+## Output-Layout
+
+```
+social/<slug>/
+├── slide_1.png Hero + Titel-Overlay
+├── slide_2.png erste ## Überschrift
+├── slide_3.png zweite ## Überschrift
+├── ...
+├── slide_N.png CTA "Ganzen Artikel lesen → Link in Bio"
+├── hero_1x1.png Hero zugeschnitten 1080×1080
+├── hero_4x5.png Hero zugeschnitten 1080×1350
+├── hero_9x16.png Hero zugeschnitten 1080×1920 (Story-Format)
+└── caption.txt Hook-Vorschläge + excerpt + Hashtags
+```
+
+## Slide-Erzeugung
+
+- Slides sind HTML/SVG mit den bestehenden CSS-Design-Tokens
+ (--deep, --teal, --yellow, --mint, --color-primary, --color-accent),
+ zu PNG gerastert. Gleiches Muster wie die Website-Templates, nur
+ anderes Zielformat → Markenbild bleibt automatisch konsistent.
+- Format: 1080×1350 (4:5) für Karussell – beste IG-Sichtbarkeit.
+- Slide 1: Hero-Bild als Hintergrund, Titel als Overlay (Barlow Condensed,
+ wie page-hero-title).
+- Slide 2..N-1: je eine ## Überschrift, ggf. mit erstem Satz darunter.
+- Slide N: CTA mit Logo + "Link in Bio".
+
+## Slide-Anzahl begrenzen (DESIGN-ENTSCHEIDUNG, offen)
+
+IG deckelt bei 10 Slides, Aufmerksamkeit bei deutlich weniger.
+Richtwert: Hero-Slide + max. 5–6 Überschriften-Slides + CTA.
+
+Bei Artikeln mit mehr ## Überschriften – zwei Optionen (zu entscheiden):
+- a) Generator nimmt die ersten N Überschriften.
+- b) Im Markdown markieren welche Überschriften "social-relevant" sind
+ (z.B. ## Titel {social} oder eine Frontmatter-Liste).
+
+Analog zur TOC-Hierarchie-Frage: nicht sofort lösen, aber festlegen
+bevor gebaut wird.
+
+## Caption (halbautomatisch)
+
+caption.txt enthält:
+- 3 vom LLM vorgeschlagene HOOKS (erste Caption-Zeile) – der Mensch wählt.
+ Der Hook ist der eine Teil den kein Template aus dem Artikel ziehen kann:
+ "Warum einen Coach?" ist ein guter Artikeltitel, aber ein schlechter
+ IG-Hook. Einsatz für Claude-in-Claude-API: 3 Varianten generieren.
+- excerpt als Caption-Körper.
+- Hashtags aus den tags (+ feste Marken-Hashtags aus config).
+
+Hook NICHT automatisch final setzen – das ist der eine Schritt zu viel,
+der aus "spart Zeit" ein "klingt nach Bot" macht.
+
+## Architektur-Einordnung
+
+- Neuer Output-Schritt im Generator, KEIN neuer Stack.
+- Gleiche content/blog/<slug>/-Eingabe, Output in social/<slug>/.
+- Hero-Crops nutzen den bestehenden ImageProcessor (ImageMagick).
+- Slide-Rendering: HTML→PNG (z.B. headless Chromium oder bestehende
+ SVG-Pipeline). Render-Technik teilweise schon vorhanden.
+
+## Voraussetzung
+
+Setzt den Markdown-Loader (Phase 1) voraus: sauberes Hero
+(hero_<slug>_1) und ## Überschriften müssen strukturiert vorliegen.
+Erst Loader, dann Social-Bäcker.
+
+---
+
+# Roadmap-Einordnung
+
+Diese SPEC beschreibt zwei aufeinander aufbauende Phasen. Hier ihr Platz
+im Gesamtbild des Projekts. Reihenfolge ist bindend: Phase 2 setzt Phase 1
+voraus.
+
+## Bereits erledigt (v0.1.0)
+
+- Vollständige Seitenstruktur: Start, Coaching, Blog, Camps, Tools, Kontakt
+- Kontaktformular + Event-Anmeldung über Postfix (DKIM/SPF/DMARC, -f Envelope)
+- Event-System mit Galerien (masonry/carousel/slideshow) + Lightbox
+- SEO: JSON-LD Schemas, Meta-Descriptions, Breadcrumbs
+- Responsive Bildverarbeitung (WebP, 3 Größen), EXIF-auto-orient
+- CSS-Refactoring, event.ftlh modularisiert (Freemarker-Macros)
+- Markdown-Loader v1 (Hero einzeln + Tags) – Grundgerüst steht
+
+## Phase 1 – Markdown-Blog-Loader (NÄCHSTER SCHRITT)
+
+Siehe oberen Teil dieser SPEC. Erweitert den v1-Loader auf:
+mehrere Heroes (Carousel), Card, Gallery, hero_style, unpublish,
+meta_*, canonical_url, strikte Bild-Konventionen + Validierung.
+→ Blog-Artikel als content/blog/<slug>/artikel.md statt handgeschriebenem SQL.
+
+## Phase 2 – Social-Asset-Generator
+
+Siehe oberen Teil dieser SPEC. Karussell-PNGs + caption.txt nach
+social/<slug>/ für Upload in Buffer. Setzt Phase 1 voraus.
+
+## Phase 3+ – Weitere Kandidaten (Priorität offen, durch OWNER zu setzen)
+
+Aus den bisherigen Sessions als Kandidaten notiert – NICHT priorisiert,
+Owner entscheidet:
+
+- TOC-Hierarchie-Bug: Inhaltsverzeichnis in Blog-Artikeln nummeriert
+ flach durch, ignoriert h2/h3-Ebenen. Kleiner main.js-Fix + CSS.
+ (Guter erster Task zum Ausprobieren von Claude Code.)
+- Eingebettete Bilder im Markdown: AttributeProvider im MarkdownRenderer,
+ der site.contextPath vor src setzt.
+- Lightbox-Verbesserungen (Pfeil-Navigation in der Galerie).
+- RSS-Feed für Blog.
+- Such-Funktion (clientseitig, z.B. Lunr.js).
+- Server-Härtung: fail2ban, unattended-upgrades, Apache-Hardening.
+- Story-Format für Social (Phase 2 erweitern).
+- Loader für weitere Inhaltstypen (Events, Pages) – aktuell bewusst
+ SQL-only; nur falls Pflegeaufwand es rechtfertigt.
+
+## Langfristig / Kommerzialisierung
+
+- Mehrsprachigkeit (EN) – im Datenmodell aktuell nicht vorgesehen.
+- Web-UI als optionaler Aufsatz über dem Markdown-Format (Decap-CMS-artig).
+ Markdown bleibt kanonisch, UI ist nur Editor. Datenmodell muss dafür
+ NICHT angefasst werden.
+- Zielgruppe: technisch interessierte Nicht-Developer (Webagenturen,
+ digitale Nomaden). Differenzierung gegenüber WordPress/Jekyll:
+ lokal-first, geräte-souverän, "Speichern = veröffentlichen", kein
+ Plugin-Höllenloch.
+
+## Owner-Hoheit
+
+Offene Punkte, bekannte Fehler und die endgültige Roadmap-Priorisierung
+pflegt der Owner. Obige Phase-3-Liste ist Vorschlag/Gedächtnisstütze
+aus den Entwicklungs-Sessions, keine festgelegte Reihenfolge.
import de.laktatnebel.product.websitegenerator.config.SiteConfig;
import de.laktatnebel.product.websitegenerator.generator.SiteGenerator;
import de.laktatnebel.product.websitegenerator.loader.ContentBuild;
+import de.laktatnebel.product.websitegenerator.social.SocialGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
* java -jar ssg.jar --dry-run → ohne Dateien zu schreiben
* java -jar ssg.jar --emit-content → Markdown-Artikel zu SQL generieren (db/articles/_generated/)
* java -jar ssg.jar --check-content → Markdown-Artikel prüfen (Lint, schreibt nichts)
+ * java -jar ssg.jar --emit-social → Social-Assets erzeugen (social/<slug>/)
*
* Die Content-Modi (--emit-content / --check-content) laufen eigenständig
* und starten NICHT den Generator – sie sind als separater Schritt im
boolean dryRun = false;
boolean emitContent = false;
boolean checkContent = false;
+ boolean emitSocial = false;
for (String arg : args) {
switch (arg) {
checkContent = true;
log.info("Modus: check-content (Lint, schreibt nichts)");
}
+ case "--emit-social" -> {
+ emitSocial = true;
+ log.info("Modus: emit-social (Social-Assets erzeugen)");
+ }
default -> configPath = Path.of(arg);
}
}
return;
}
+ // ── Social-Modus: Social-Assets erzeugen ─────────────────────
+ if (emitSocial) {
+ if (config.socialTemplate == null) {
+ log.error("paths.social_template fehlt in config.yaml – abgebrochen.");
+ System.exit(1);
+ }
+ try {
+ int count = new SocialGenerator(
+ config.contentPath,
+ config.socialOutputPath,
+ config.socialTemplate,
+ config.baseUrl,
+ config.socialHashtags,
+ config.socialFont
+ ).run();
+ log.info("Social-Lauf abgeschlossen: {} Artikel verarbeitet", count);
+ } catch (Exception e) {
+ log.error("Social-Lauf fehlgeschlagen: {}", e.getMessage(), e);
+ System.exit(1);
+ }
+ return;
+ }
+
// ── Content-Modus: Markdown → SQL (ohne Generator) ────────────
if (emitContent || checkContent) {
try {
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.List;
import java.util.Map;
/**
public final String templatesPath;
public final Path staticPath;
public final String dbSchema;
- public final Path contentPath; // Markdown-Quelle (content/)
- public final Path articlesPath; // Ziel für generierte SQLs (db/articles/)
+ public final Path contentPath; // Markdown-Quelle (content/)
+ public final Path articlesPath; // Ziel für generierte SQLs (db/articles/)
+ public final Path socialOutputPath; // Ziel für Social-Assets (social/)
+ public final Path socialTemplate; // SVG-Template für Slides
+ public final List<String> socialHashtags; // Feste Marken-Hashtags
+ public final String socialFont; // ImageMagick-Fontname für Slide-Text
// Blog
public final double navTagCoverage;
dbSchema = (String) db.getOrDefault("schema", "public");
contentPath = Path.of((String) paths.getOrDefault("content", "content"));
articlesPath = Path.of((String) paths.getOrDefault("articles", "db/articles"));
+ socialOutputPath = Path.of((String) paths.getOrDefault("social", "social"));
+ String st = (String) paths.getOrDefault("social_template", null);
+ socialTemplate = st != null ? Path.of(st) : null;
+
+ Map<String, Object> socialCfg = raw.containsKey("social_assets")
+ ? (Map<String, Object>) raw.get("social_assets") : Map.of();
+ Object hashtagsRaw = socialCfg.get("hashtags");
+ socialHashtags = hashtagsRaw instanceof List<?> hl
+ ? hl.stream().map(Object::toString).toList()
+ : List.of();
+ socialFont = (String) socialCfg.getOrDefault("font", "DejaVu-Sans-Bold");
// Blog
Map<String, Object> blog = raw.containsKey("blog") ? (Map<String, Object>) raw.get("blog") : Map.of();
--- /dev/null
+package de.laktatnebel.product.websitegenerator.social;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Rendert einzelne Slides und Hero-Crops via ImageMagick.
+ *
+ * Pipeline pro Slide (1080×1350, 4:5):
+ * 1. Hero-Bild auf 1080×1350 croppen (fill + center)
+ * 2. SVG-Template proportional auf 1080px Breite skalieren (→ 1080×1920)
+ * 3. SVG-PNG am unteren Rand über Foto kompositionieren (Deko-Balken sichtbar)
+ * 4. Dunkel-Gradient für Lesbarkeit + Text-Annotation
+ */
+public final class SlideRenderer {
+
+ private static final Logger log = LoggerFactory.getLogger(SlideRenderer.class);
+
+ // Karussell: 4:5
+ public static final int CAROUSEL_W = 1080;
+ public static final int CAROUSEL_H = 1350;
+
+ // Story: 9:16
+ public static final int STORY_W = 1080;
+ public static final int STORY_H = 1920;
+
+ // Square: 1:1
+ public static final int SQUARE_W = 1080;
+ public static final int SQUARE_H = 1080;
+
+ // SVG-Template-Originalhöhe (1080×1920)
+ private static final int SVG_NATIVE_H = 1920;
+
+ private final Path svgTemplate;
+ private final String font;
+ private Path cachedSvgPng;
+
+ public SlideRenderer(Path svgTemplate, String font) {
+ this.svgTemplate = svgTemplate;
+ this.font = font != null ? font : "DejaVu-Sans-Bold";
+ }
+
+ // ── Hero-Crops ───────────────────────────────────────────────
+
+ public void cropHero(Path heroImage, Path outDir) throws IOException, InterruptedException {
+ Files.createDirectories(outDir);
+ crop(heroImage, outDir.resolve("hero_4x5.png"), CAROUSEL_W, CAROUSEL_H);
+ crop(heroImage, outDir.resolve("hero_9x16.png"), STORY_W, STORY_H);
+ crop(heroImage, outDir.resolve("hero_1x1.png"), SQUARE_W, SQUARE_H);
+ }
+
+ private void crop(Path src, Path dst, int w, int h) throws IOException, InterruptedException {
+ run(cmd("magick",
+ src.toString(),
+ "-auto-orient",
+ "-resize", w + "x" + h + "^",
+ "-gravity", "Center",
+ "-extent", w + "x" + h,
+ dst.toString()));
+ }
+
+ // ── Slide rendern ────────────────────────────────────────────
+
+ public void renderSlide(SocialSlide slide, int index, Path outDir)
+ throws IOException, InterruptedException {
+ Files.createDirectories(outDir);
+ Path out = outDir.resolve("slide_" + index + ".png");
+
+ Path bg = buildBackground(slide.heroImage(), outDir);
+ Path base = composite(bg, outDir, CAROUSEL_W, CAROUSEL_H);
+ annotateText(base, slide, out);
+
+ // Temporäre Zwischendateien aufräumen
+ Files.deleteIfExists(bg);
+ if (!base.equals(out)) Files.deleteIfExists(base);
+ log.debug("→ {}", out.getFileName());
+ }
+
+ /** Foto auf 1080×1350 croppen → temporäre Datei */
+ private Path buildBackground(Path heroImage, Path tmpDir)
+ throws IOException, InterruptedException {
+ Path bg = tmpDir.resolve("_bg_tmp.png");
+ crop(heroImage, bg, CAROUSEL_W, CAROUSEL_H);
+ return bg;
+ }
+
+ /**
+ * SVG-Overlay über Foto kompositionieren.
+ * SVG wird auf 1080px Breite skaliert (→ 1920px hoch),
+ * dann am unteren Rand des 1350px-Canvas ausgerichtet.
+ */
+ private Path composite(Path bg, Path tmpDir, int w, int h)
+ throws IOException, InterruptedException {
+ Path svgPng = getSvgPng(tmpDir);
+ Path out = tmpDir.resolve("_comp_tmp.png");
+
+ // Offset: SVG-Boden = Canvas-Boden → svgTop = h - SVG_NATIVE_H
+ int yOffset = h - SVG_NATIVE_H; // negativ bei 4:5: 1350-1920 = -570
+
+ run(cmd("magick", bg.toString(),
+ svgPng.toString(),
+ "-geometry", "+0" + yOffset,
+ "-composite",
+ out.toString()));
+ return out;
+ }
+
+ /** SVG einmalig in PNG umwandeln und cachen (auf 1080px Breite skaliert). */
+ private Path getSvgPng(Path tmpDir) throws IOException, InterruptedException {
+ if (cachedSvgPng != null && Files.exists(cachedSvgPng)) return cachedSvgPng;
+ Path out = tmpDir.resolve("_svg_overlay.png");
+ run(cmd("magick", svgTemplate.toString(),
+ "-resize", CAROUSEL_W + "x", // Breite fixieren, Höhe proportional
+ out.toString()));
+ cachedSvgPng = out;
+ return out;
+ }
+
+ /** Text auf das kompositionierte Bild schreiben. */
+ private void annotateText(Path base, SocialSlide slide, Path out)
+ throws IOException, InterruptedException {
+
+ List<String> args = new ArrayList<>();
+ args.add("magick");
+ args.add(base.toString());
+
+ switch (slide.type()) {
+ case HERO -> {
+ // Großer Titel, mittig-unten
+ addTextArgs(args, slide.heading(), 80, "-gravity", "South", "+0+250");
+ if (slide.subtext() != null) {
+ addTextArgs(args, slide.subtext(), 42, "-gravity", "South", "+0+170");
+ }
+ }
+ case SECTION -> {
+ // Überschrift + optionaler erster Satz
+ addTextArgs(args, slide.heading(), 72, "-gravity", "South", "+0+250");
+ if (slide.subtext() != null) {
+ addTextArgs(args, slide.subtext(), 38, "-gravity", "South", "+0+170");
+ }
+ }
+ case CTA -> {
+ addTextArgs(args, slide.heading(), 60, "-gravity", "Center", "+0+0");
+ }
+ }
+
+ args.add(out.toString());
+ run(args.toArray(new String[0]));
+ }
+
+ private void addTextArgs(List<String> args, String text,
+ int size, String... gravity) {
+ String wrapped = wrapText(text, 28);
+ for (String a : gravity) args.add(a);
+ args.addAll(List.of(
+ "-font", font,
+ "-pointsize", String.valueOf(size),
+ "-fill", "white",
+ "-stroke", "#00000088",
+ "-strokewidth", "2",
+ "-annotate", gravity[gravity.length - 1], // letzte gravity-Option = offset
+ wrapped
+ ));
+ }
+
+ // ── ImageMagick-Aufruf ───────────────────────────────────────
+
+ private void run(String... cmd) throws IOException, InterruptedException {
+ // Versuche erst "magick" (ImageMagick 7), dann "convert" (6)
+ String[] actualCmd = cmd;
+ if ("magick".equals(cmd[0])) {
+ try {
+ Process check = new ProcessBuilder("magick", "-version")
+ .redirectErrorStream(true).start();
+ if (check.waitFor() != 0) {
+ actualCmd = cmd.clone();
+ actualCmd[0] = "convert";
+ }
+ } catch (IOException ignored) {
+ actualCmd = cmd.clone();
+ actualCmd[0] = "convert";
+ }
+ }
+
+ ProcessBuilder pb = new ProcessBuilder(actualCmd);
+ pb.redirectErrorStream(true);
+ Process proc = pb.start();
+ String out = new String(proc.getInputStream().readAllBytes());
+ int exit = proc.waitFor();
+ if (exit != 0) {
+ throw new IOException("ImageMagick Fehler (exit " + exit + "): " + out
+ + "\nKommando: " + String.join(" ", actualCmd));
+ }
+ }
+
+ private String[] cmd(String... parts) { return parts; }
+
+ // ── Hilfsmethoden ────────────────────────────────────────────
+
+ /** Einfacher Zeilenumbruch bei maxCharsPerLine Zeichen (an Wortgrenzen). */
+ static String wrapText(String text, int maxCharsPerLine) {
+ if (text == null) return "";
+ if (text.length() <= maxCharsPerLine) return text;
+ StringBuilder sb = new StringBuilder();
+ String[] words = text.split("\\s+");
+ int lineLen = 0;
+ for (String word : words) {
+ if (lineLen > 0 && lineLen + 1 + word.length() > maxCharsPerLine) {
+ sb.append('\n');
+ lineLen = 0;
+ } else if (lineLen > 0) {
+ sb.append(' ');
+ lineLen++;
+ }
+ sb.append(word);
+ lineLen += word.length();
+ }
+ return sb.toString();
+ }
+}
--- /dev/null
+package de.laktatnebel.product.websitegenerator.social;
+
+import de.laktatnebel.product.websitegenerator.loader.ContentLoader;
+import de.laktatnebel.product.websitegenerator.loader.LoadedArticle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.*;
+import java.util.regex.*;
+import java.util.stream.Collectors;
+
+/**
+ * Erzeugt Social-Media-Assets für alle Blog-Artikel:
+ *
+ * social/<slug>/
+ * slide_1.png – Hero + Titel
+ * slide_2.png – erste h2-Überschrift
+ * ...
+ * slide_N.png – CTA
+ * hero_4x5.png – Hero zugeschnitten 1080×1350
+ * hero_9x16.png – Hero zugeschnitten 1080×1920
+ * hero_1x1.png – Hero zugeschnitten 1080×1080
+ * caption.txt – Hook-Vorschläge + Excerpt + Hashtags
+ *
+ * Aufgerufen mit --emit-social aus Main.
+ */
+public final class SocialGenerator {
+
+ private static final Logger log = LoggerFactory.getLogger(SocialGenerator.class);
+
+ private static final int MAX_SECTION_SLIDES = 6;
+ private static final String SOCIAL_MARKER = "{social}";
+ private static final Pattern H2_PATTERN =
+ Pattern.compile("^##\\s+(.+)$", Pattern.MULTILINE);
+
+ private final Path contentRoot;
+ private final Path socialRoot;
+ private final Path svgTemplate;
+ private final String baseUrl;
+ private final List<String> fixedHashtags;
+ private final String font;
+
+ public SocialGenerator(Path contentRoot, Path socialRoot, Path svgTemplate,
+ String baseUrl, List<String> fixedHashtags, String font) {
+ this.contentRoot = contentRoot;
+ this.socialRoot = socialRoot;
+ this.svgTemplate = svgTemplate;
+ this.baseUrl = baseUrl;
+ this.fixedHashtags = fixedHashtags != null ? fixedHashtags : List.of();
+ this.font = font;
+ }
+
+ public int run() throws IOException {
+ if (!Files.exists(svgTemplate)) {
+ log.error("SVG-Template nicht gefunden: {}", svgTemplate);
+ return 0;
+ }
+
+ ContentLoader loader = new ContentLoader(true);
+ List<LoadedArticle> articles = loader.loadBlogArticles(contentRoot);
+ if (articles.isEmpty()) {
+ log.info("Keine Artikel gefunden – Social-Generator übersprungen.");
+ return 0;
+ }
+
+ SlideRenderer renderer = new SlideRenderer(svgTemplate, font);
+ int generated = 0;
+
+ for (LoadedArticle article : articles) {
+ try {
+ generate(article, renderer);
+ generated++;
+ } catch (Exception e) {
+ log.error("✗ {}: {}", article.slug(), e.getMessage(), e);
+ }
+ }
+
+ log.info("Social-Assets erzeugt: {} Artikel", generated);
+ return generated;
+ }
+
+ // ── Pro Artikel ──────────────────────────────────────────────
+
+ private void generate(LoadedArticle article, SlideRenderer renderer)
+ throws IOException, InterruptedException {
+
+ String folderSlug = article.slug().replace("blog/", "");
+ Path outDir = socialRoot.resolve(folderSlug);
+ Files.createDirectories(outDir);
+
+ Path heroImage = resolveHeroImage(article);
+ if (heroImage == null) {
+ log.warn("⚠ {}: kein Hero-Bild → Social-Assets übersprungen", article.slug());
+ return;
+ }
+
+ renderer.cropHero(heroImage, outDir);
+
+ List<SocialSlide> slides = planSlides(article, heroImage);
+ for (int i = 0; i < slides.size(); i++) {
+ renderer.renderSlide(slides.get(i), i + 1, outDir);
+ }
+
+ writeCaption(article, outDir);
+ log.info("✓ {} → {} Slides + hero-crops + caption.txt", article.slug(), slides.size());
+ }
+
+ // ── Slides planen ────────────────────────────────────────────
+
+ private List<SocialSlide> planSlides(LoadedArticle article, Path heroImage) {
+ List<SocialSlide> slides = new ArrayList<>();
+
+ slides.add(new SocialSlide(
+ SocialSlide.Type.HERO,
+ article.title(),
+ article.subtitle(),
+ heroImage
+ ));
+
+ for (HeadingEntry h : selectHeadings(extractHeadings(article.body()))) {
+ slides.add(new SocialSlide(
+ SocialSlide.Type.SECTION,
+ h.text(),
+ h.firstSentence(),
+ heroImage
+ ));
+ }
+
+ slides.add(new SocialSlide(
+ SocialSlide.Type.CTA,
+ "Ganzen Artikel lesen\n→ Link in Bio",
+ null,
+ heroImage
+ ));
+
+ return slides;
+ }
+
+ private record HeadingEntry(String text, String firstSentence, boolean social) {}
+
+ private List<HeadingEntry> extractHeadings(String body) {
+ List<HeadingEntry> result = new ArrayList<>();
+ String[] lines = body.split("\n", -1);
+
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i].trim();
+ Matcher m = H2_PATTERN.matcher(line);
+ if (!m.matches()) continue;
+
+ String raw = m.group(1).trim();
+ boolean social = raw.contains(SOCIAL_MARKER);
+ String text = raw.replace(SOCIAL_MARKER, "").trim();
+
+ String firstSentence = null;
+ for (int j = i + 1; j < lines.length; j++) {
+ String l = lines[j].trim();
+ if (l.isEmpty() || l.startsWith("#") || l.startsWith("!") || l.startsWith("-"))
+ continue;
+ String clean = l.replaceAll("[*_`]", "")
+ .replaceAll("\\[([^\\]]+)\\]\\([^)]+\\)", "$1")
+ .trim();
+ if (!clean.isEmpty()) {
+ int dot = firstSentenceEnd(clean);
+ firstSentence = dot >= 0 ? clean.substring(0, dot + 1) : clean;
+ if (firstSentence.length() > 120)
+ firstSentence = firstSentence.substring(0, 117) + "…";
+ }
+ break;
+ }
+
+ result.add(new HeadingEntry(text, firstSentence, social));
+ }
+ return result;
+ }
+
+ private List<HeadingEntry> selectHeadings(List<HeadingEntry> all) {
+ boolean anyMarked = all.stream().anyMatch(HeadingEntry::social);
+ return all.stream()
+ .filter(h -> !anyMarked || h.social())
+ .limit(MAX_SECTION_SLIDES)
+ .collect(Collectors.toList());
+ }
+
+ private int firstSentenceEnd(String s) {
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '.' || c == '!' || c == '?') return i;
+ }
+ return -1;
+ }
+
+ // ── Caption ──────────────────────────────────────────────────
+
+ private void writeCaption(LoadedArticle article, Path outDir) throws IOException {
+ StringBuilder sb = new StringBuilder();
+
+ sb.append("=== HOOK-VORSCHLÄGE (einen wählen und anpassen) ===\n");
+ sb.append("(Placeholder – manuell ersetzen oder LLM-Integration aktivieren)\n");
+ sb.append("[Hook 1: Frage stellen, z.B. \"Warum ...?\"]\n");
+ sb.append("---\n");
+ sb.append("[Hook 2: Überraschung/Kontrast, z.B. \"Alle denken X, aber ...\"]\n");
+ sb.append("---\n");
+ sb.append("[Hook 3: Nutzen, z.B. \"So sparst du ... mit ...\"]\n");
+ sb.append("\n");
+
+ sb.append("=== CAPTION ===\n");
+ if (article.excerpt() != null && !article.excerpt().isBlank()) {
+ sb.append(article.excerpt()).append("\n");
+ } else {
+ sb.append("[Excerpt fehlt – in artikel.md ergänzen]\n");
+ }
+ sb.append("\n");
+
+ sb.append("=== HASHTAGS ===\n");
+ String tags = article.tags().stream()
+ .map(t -> "#" + t.replace("-", "").replace(" ", ""))
+ .collect(Collectors.joining(" "));
+ String fixed = fixedHashtags.stream()
+ .map(t -> t.startsWith("#") ? t : "#" + t)
+ .collect(Collectors.joining(" "));
+ sb.append(tags);
+ if (!tags.isEmpty() && !fixed.isEmpty()) sb.append(" ");
+ sb.append(fixed).append("\n");
+ sb.append("\n");
+
+ sb.append("=== LINK IN BIO ===\n");
+ sb.append(baseUrl).append("/").append(article.slug()).append("\n");
+
+ Files.writeString(outDir.resolve("caption.txt"), sb.toString());
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────
+
+ private Path resolveHeroImage(LoadedArticle article) {
+ if (article.heroImageFiles().isEmpty()) return null;
+ String folderSlug = article.slug().replace("blog/", "");
+ return contentRoot.resolve("blog").resolve(folderSlug)
+ .resolve(article.heroImageFiles().get(0));
+ }
+}
--- /dev/null
+package de.laktatnebel.product.websitegenerator.social;
+
+import java.nio.file.Path;
+
+/**
+ * Eine geplante Instagram-Karussell-Folie.
+ */
+public record SocialSlide(
+ Type type,
+ String heading, // Überschrift (bereinigt, ohne {social}-Marker)
+ String subtext, // erster Satz nach der Überschrift (optional, null)
+ Path heroImage // immer das Hero-Bild als Hintergrund
+) {
+ public enum Type { HERO, SECTION, CTA }
+}