]> git.laktatnebel.de Git - websitegenerator.git/commitdiff
Markdown-Blog-Loader vollständig implementiert (Schritt 1–3 laut SPEC)
authorClaude Code AI <claude@poseidon.laktatnebel.de>
Thu, 25 Jun 2026 08:43:07 +0000 (10:43 +0200)
committerClaude Code AI <claude@poseidon.laktatnebel.de>
Thu, 25 Jun 2026 08:43:07 +0000 (10:43 +0200)
- LoadedArticle: heroImageFiles (Liste), cardImageFile, galleryGroups,
  heroStyle, unpublishAt, metaTitle, metaDesc, canonicalUrl
- ContentLoader: strikte Bild-Klassifizierung per hero_/card_/gallery_-Präfix,
  Slug-Validierung, hero_style-Defaultlogik, neue Frontmatter-Felder
  (publish, unpublish, author, meta_title, meta_desc, canonical_url, hero_style)
- SqlEmitter: hero_media_id + card_media_id per Subquery, content_media-INSERTs
  für Heroes (role=hero) und Galerien (role=body), unpublish_at, SEO-Felder
- ContentBuild: printLint auf neue API aktualisiert
- Main: --emit-content / --check-content Argumente verdrahtet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
.gitignore
SPEC.md [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/Main.java
src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java
src/main/java/de/laktatnebel/product/websitegenerator/loader/ContentBuild.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/loader/ContentLoader.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/loader/LoadedArticle.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/loader/SqlEmitter.java [new file with mode: 0644]

index bfc3202592fe8d4ef7621ea15d1916da9f640b6b..0df845060b2c8ecdc665be962f62d0cf86a663ad 100644 (file)
@@ -4,3 +4,4 @@
 bin
 target
 .dbeaver/
+*.tar.gz
diff --git a/SPEC.md b/SPEC.md
new file mode 100644 (file)
index 0000000..b7ee564
--- /dev/null
+++ b/SPEC.md
@@ -0,0 +1,247 @@
+# SPEC: Markdown-Blog-Loader für den websitegenerator
+
+Stand: Design abgeschlossen, Implementierung offen.
+Zielprojekt: `websitegenerator` (Java 17, Maven) + `www.triathlon-coaching.com` (DB/Theme).
+
+## Zweck
+
+Blog-Artikel sollen als Markdown-Dateien mit YAML-artigem Kopf gepflegt
+werden – nicht mehr als handgeschriebenes SQL. Der Loader liest diese
+Dateien, validiert sie und erzeugt SQL-Dateien im Stil der bestehenden
+`db/articles/*.sql`, die in den normalen `build.sh`-Ablauf einfließen.
+
+**Scope: ausschließlich Blog-Artikel.** Alles andere (Seiten, Events, Nav)
+bleibt SQL-only. Daher sind `template`, `category`, `parent`, `status`
+Konstanten, kein YAML.
+
+## Kontrollprinzip
+
+Der Loader schreibt SQL nach `db/articles/_generated/`. Diese Dateien
+können vor dem Einspielen geprüft werden (Kontrollfreak-Modus). Zwei Modi:
+
+- `--emit-content`  → SQL erzeugen (nach `_generated/`)
+- `--check-content` → nur Lint, schreibt nichts
+
+Bei Slug-Konflikt mit einem handgeschriebenen `db/articles/*.sql` gewinnt
+das manuelle File – der Markdown-Artikel wird mit Warnung übersprungen.
+
+## Datei-Layout
+
+```
+content/blog/<slug>/
+├── artikel.md
+├── hero_<slug>_1.jpg          (optional, mehrere möglich für Carousel)
+├── card_<slug>.jpg            (optional)
+├── gallery_1_<slug>_1.jpg     (optional, mehrere Galerien/Bilder)
+└── beliebiger-name.jpg        (embedded, im Markdown referenziert)
+```
+
+`<slug>` = Ordnername. Resultierender Seiten-Slug: `blog/<ordnername>`.
+
+## artikel.md – Format
+
+```markdown
+# ─────────────────────────────────────────────────────────
+# Secretary (optional – defaults apply if empty)
+# ─────────────────────────────────────────────────────────
+author: ole                  # author key (authors.slug); empty = default author (id=1)
+publish: 2026-12-24          # YYYY-MM-DD or DD.MM.YYYY; empty = file mtime
+unpublish: 2027-01-15        # optional; article hidden after this date
+
+# ─────────────────────────────────────────────────────────
+# SEO (optional – empty = sensible defaults, see table)
+# ─────────────────────────────────────────────────────────
+excerpt: Short summary for search engines and preview.
+meta_title: Custom title         # empty = article title
+meta_desc: Custom meta desc      # empty = excerpt
+canonical_url: https://...       # empty = none; only for republished content
+
+# ─────────────────────────────────────────────────────────
+# Editorial
+# ─────────────────────────────────────────────────────────
+tags: running, training, roth    # comma-separated; only existing tags are linked
+hero_style: slideshow            # fix | carousel | slideshow | random; see defaults
+
+# ═══════════════════════════════════════════════════════════
+# Article below. Title = first heading, subtitle = italic line
+# right beneath it (optional). Embed images via ![desc](file.jpg).
+# ═══════════════════════════════════════════════════════════
+
+# Laufen im Winter
+*Eine spannende Erkenntnis aus dem Training.*
+
+## Einleitung
+
+Letzte Woche stand der Solarer Berg auf dem Plan…
+```
+
+### Frontmatter-Parsing
+
+- Format "Option B": `feldname: wert`, eine Zeile pro Feld.
+- Beginnt am Dateianfang, endet bei erster Leerzeile ODER erster `#`-Zeile.
+- Feldnamen case-insensitive, Werte getrimmt.
+- Listen (`tags`): per Komma splitten, trimmen, Leere verwerfen.
+- Kommentarzeilen im Kopf beginnen mit `#` – ABER der Titel ist auch `#`.
+  Lösung: Frontmatter endet bei der ersten `#`-Zeile, also ist alles vor
+  dem Titel Frontmatter+Kommentare. Kommentar-Erkennung: Zeile beginnt mit
+  `#` und steht VOR der ersten Nicht-Kommentar-Frontmatter-Logik … einfacher:
+  Zeilen die mit `#` beginnen und KEIN gültiges `feldname: wert` Muster nach
+  dem `#` haben, werden als Kommentar verworfen. Der ARTIKEL-Titel ist die
+  erste `# `-Zeile NACH dem Frontmatter-Block. (Implementierungsdetail:
+  Frontmatter-Block läuft bis zur ersten Leerzeile; Kommentare mit `#` dürfen
+  drinstehen und werden ignoriert.)
+
+## Felder → DB-Mapping
+
+Alle Inhalts-Felder landen in `page_content` (NICHT in `pages`).
+
+| YAML | DB-Spalte | Default wenn leer |
+|------|-----------|-------------------|
+| author | author_id (via authors.slug) | id = 1 |
+| publish | publish_at | Datei-mtime |
+| unpublish | unpublish_at | NULL |
+| excerpt | excerpt | erste ~160 Zeichen des Body (an Wortgrenze) |
+| meta_title | meta_title | NULL (Generator nutzt title zur Render-Zeit) |
+| meta_desc | meta_desc | NULL (Generator nutzt excerpt zur Render-Zeit) |
+| canonical_url | canonical_url | NULL |
+| tags | content_tags (via tags.slug) | keine |
+| hero_style | page_content.hero_style | slideshow b. mehreren Heroes, NULL bei einem |
+
+Aus dem Markdown-Body abgeleitet:
+
+| Element | Quelle |
+|---------|--------|
+| title | erste `# `-Zeile (Pflicht; fehlt → Artikel überspringen + Fehler) |
+| subtitle | erste `*kursive*` Zeile direkt unter Titel, sonst NULL |
+| body | alles ab erstem `##` |
+
+Konstanten (nie YAML):
+
+| Spalte | Wert |
+|--------|------|
+| pages.slug | `blog/<ordnername>` |
+| pages.parent_id | (SELECT id FROM pages WHERE slug='blog') |
+| pages.template | 'article' |
+| page_content.category_id | (SELECT id FROM categories WHERE slug='blog') |
+| page_content.status | 'published' |
+| content_pages | Zuordnung zu category 'blog' |
+
+## Bild-Konventionen (strikt, Unterstrich-getrennt)
+
+Reservierte Präfixe greifen NUR mit Unterstrich: `hero_`, `card_`, `gallery_`.
+`card-game.jpg` ist embedded, kein Card-Bild.
+
+| Klasse | Muster | Beispiel (slug = laufen-im-winter) |
+|--------|--------|-------------------------------------|
+| Hero | `hero_<slug>_<N>.<ext>` | hero_laufen-im-winter_1.jpg |
+| Card | `card_<slug>.<ext>` | card_laufen-im-winter.jpg |
+| Gallery | `gallery_<G>_<slug>_<N>.<ext>` | gallery_1_laufen-im-winter_2.jpg |
+| Embedded | `<name>.<ext>` | solarer-berg.jpg |
+
+Endungen: jpg, jpeg, png, webp.
+
+### Zerlegung
+
+Der Slug ist durch den Ordner bekannt; im Dateinamen muss er aber MIT dem
+Ordner übereinstimmen (sonst Warnung). Zerlegung per Split an `_`:
+
+- Hero: `hero` _ `<slug>` _ `<N>` . ext   → N = Sortierreihenfolge
+- Card: `card` _ `<slug>` . ext
+- Gallery: `gallery` _ `<G>` _ `<slug>` _ `<N>` . ext
+- Slug-Teil kann selbst Bindestriche enthalten (laufen-im-winter), aber
+  KEINE Unterstriche – Unterstrich ist Strukturtrenner.
+
+### Zuordnung
+
+- Hero → `pages.hero_media_id` = Bild N=1; alle Heroes zusätzlich in
+  `content_media` mit `media_role='hero'`, position=N.
+- Card → `page_content.card_media_id`. Fehlt card_* → nimm hero_<slug>_1.
+- Gallery → `content_media` mit `media_role='body'` (vorerst; eigene
+  gallery-Rolle später denkbar). Gruppiert nach G, sortiert nach N,
+  position fortlaufend.
+- Embedded → bleibt im Markdown. Pfad wird zur Render-Zeit normalisiert
+  (Generator-Sache, nicht Loader).
+
+### hero_style Defaults
+
+- YAML-Feld, Optionen: `fix` (=NULL), `carousel`, `slideshow`, `random`.
+- Leer + 1 Hero-Bild → NULL (fix).
+- Leer + mehrere Hero-Bilder → `slideshow`.
+- Gesetzt → wird übernommen, auch bei nur 1 Bild (Carousel mit einem Bild
+  ist gültiger Zwischenzustand, Bild 2 kommt evtl. später → KEINE Warnung).
+
+## Validierung (immer Warnung + weitermachen, NIE Abbruch)
+
+- Slug im Dateinamen ≠ Ordnername → Bild ignorieren, warnen.
+- Hero-Nummern mit Lücke (kein `_1` vorhanden, aber `_2`) → warnen.
+- Gallery-Bild ohne erkennbare G/N → warnen, ignorieren.
+- Artikel ohne `# `-Titel → Artikel überspringen, Fehler loggen.
+- Slug-Duplikat zwischen zwei content-Ordnern → Fehler, Artikel überspringen.
+
+Lint-Ausgabe (`--check-content`) soll pro Artikel zeigen: Slug, Titel,
+Subtitle, Excerpt-Quelle (Frontmatter/auto), Tags, gefundene Bilder je
+Klasse, publish/unpublish, Autor, hero_style. Plus alle Warnungen.
+
+## SQL-Ausgabe (Stil der bestehenden Files)
+
+- `SET search_path TO :"schema";`
+- Positionale INSERTs `VALUES (DEFAULT, ...)`.
+- Verknüpfung über `(SELECT id FROM :"schema".pages WHERE slug='...')`.
+- Body dollar-quoted: `$md$...$md$` (Fallback `$md1$` etc. falls `$md$`
+  im Text vorkommt).
+- content_tags: ein INSERT pro Tag, content_id via pages-slug-Subquery
+  (funktioniert weil page_content.id synchron zu pages.id).
+- Dateiname der generierten SQL: `blog_<ordnername>.sql`.
+
+WICHTIG – exakte Spaltenreihenfolge aus `db/schema.sql` einhalten:
+
+pages: id, slug, parent_id, template, sort_order, in_sitemap,
+       locked_category, external_url, hero_media_id, created_at, updated_at
+
+page_content: id, page_id, author_id, category_id, title, subtitle,
+       section_label, excerpt, body, body_secondary, hero_media_id,
+       hero_style, card_media_id, meta_title, meta_desc, canonical_url,
+       status, publish_at, unpublish_at, created_at, updated_at
+
+## build.sh-Integration
+
+Reihenfolge:
+1. `java -jar ...jar config.yaml --emit-content`
+2. manuelle `db/articles/*.sql` einspielen (alphabetisch; `003_blog.sql`
+   legt die blog-Parent-Seite an → MUSS vor generierten laufen)
+3. generierte `db/articles/_generated/*.sql` einspielen
+
+## config.yaml
+
+```yaml
+paths:
+  content:  "content"       # Markdown-Quelle
+  articles: "db/articles"   # Ziel für _generated/
+```
+Beide mit Defaults, optional.
+
+## Vorhandener Code-Stand (vor diesem Spec-Update gebaut)
+
+Es existieren bereits diese Klassen im Package
+`de.laktatnebel.product.websitegenerator.loader`:
+- LoadedArticle (Record) – muss um card/gallery/hero_style erweitert werden
+- ContentLoader – Bild-Klassifizierung + Validierung fehlt noch
+- SqlEmitter – card_media_id, content_media (hero/gallery) fehlen noch
+- ContentBuild – Orchestrierung, Lint, Konflikt-Erkennung (steht)
+
+Die erste Version deckte nur Hero (einzeln) + Tags ab. Dieser Spec
+erweitert auf: mehrere Heroes, Card, Gallery, hero_style, unpublish,
+meta_*, canonical_url, strikte Bild-Konventionen + Validierung.
+
+## Nächste Schritte (für Claude Code)
+
+1. LoadedArticle um Felder erweitern: cardImageFile, heroImageFiles (Liste),
+   galleryGroups (Map<Integer,List<String>>), heroStyle, unpublishAt,
+   metaTitle, metaDesc, canonicalUrl.
+2. ContentLoader: Bild-Scanner mit strikter Präfix/Slug/Nummer-Zerlegung,
+   Validierung + Warnungen, hero_style-Default-Logik.
+3. SqlEmitter: card_media_id setzen, content_media-INSERTs für Heroes
+   (role=hero) und Galerien (role=body), hero_style in page_content.
+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.
index 38f17e51f156413cf4a9d1071cf5dfa1c6ad9e50..63a7729fa240b8cae9563bf0d530f427e4766521 100644 (file)
@@ -2,6 +2,7 @@ package de.laktatnebel.product.websitegenerator;
 
 import de.laktatnebel.product.websitegenerator.config.SiteConfig;
 import de.laktatnebel.product.websitegenerator.generator.SiteGenerator;
+import de.laktatnebel.product.websitegenerator.loader.ContentBuild;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -14,6 +15,12 @@ import java.nio.file.Path;
  *   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
+ *   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)
+ *
+ * Die Content-Modi (--emit-content / --check-content) laufen eigenständig
+ * und starten NICHT den Generator – sie sind als separater Schritt im
+ * build.sh VOR dem Einspielen der SQL-Dateien gedacht.
  */
 public class Main {
 
@@ -23,15 +30,26 @@ public class Main {
         log.info("=== Laktatnebel SSG ===");
 
         // ── Argumente parsen ──────────────────────────────────────────
-        Path configPath = Path.of("config.yaml");
-        boolean dryRun  = false;
+        Path configPath     = Path.of("config.yaml");
+        boolean dryRun       = false;
+        boolean emitContent  = false;
+        boolean checkContent = 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);
+            switch (arg) {
+                case "--dry-run" -> {
+                    dryRun = true;
+                    log.info("Modus: dry-run (keine Dateien werden geschrieben)");
+                }
+                case "--emit-content" -> {
+                    emitContent = true;
+                    log.info("Modus: emit-content (Markdown → SQL)");
+                }
+                case "--check-content" -> {
+                    checkContent = true;
+                    log.info("Modus: check-content (Lint, schreibt nichts)");
+                }
+                default -> configPath = Path.of(arg);
             }
         }
 
@@ -46,6 +64,23 @@ public class Main {
             return;
         }
 
+        // ── Content-Modus: Markdown → SQL (ohne Generator) ────────────
+        if (emitContent || checkContent) {
+            try {
+                int count = new ContentBuild().run(
+                        config.contentPath,
+                        config.articlesPath,
+                        config.dbSchema,
+                        checkContent   // checkOnly
+                );
+                log.info("Content-Lauf abgeschlossen: {} Artikel verarbeitet", count);
+            } catch (Exception e) {
+                log.error("Content-Lauf fehlgeschlagen: {}", e.getMessage(), e);
+                System.exit(1);
+            }
+            return; // im Content-Modus NICHT den Generator starten
+        }
+
         // ── Build starten ─────────────────────────────────────────────
         long start = System.currentTimeMillis();
         try {
@@ -59,7 +94,6 @@ public class Main {
                 log.warn("{} Fehler aufgetreten – siehe Log", result.errorCount());
                 System.exit(2);
             }
-
         } catch (Exception e) {
             log.error("Build fehlgeschlagen: {}", e.getMessage(), e);
             System.exit(1);
index dd959a663374b21c73921d9f4fdef948cbeecb16..237df049d60f5dbf863ae9d1cd0dbc56882096e2 100644 (file)
@@ -37,6 +37,8 @@ public class SiteConfig {
     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/)
 
     // Blog
     public final double navTagCoverage;
@@ -94,6 +96,8 @@ public class SiteConfig {
         String sp = (String) paths.getOrDefault("static", null);
         staticPath        = sp != null ? Path.of(sp) : null;
         dbSchema          = (String) db.getOrDefault("schema", "public");
+        contentPath       = Path.of((String) paths.getOrDefault("content",  "content"));
+        articlesPath      = Path.of((String) paths.getOrDefault("articles", "db/articles"));
 
         // Blog
         Map<String, Object> blog = raw.containsKey("blog") ? (Map<String, Object>) raw.get("blog") : Map.of();
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/loader/ContentBuild.java b/src/main/java/de/laktatnebel/product/websitegenerator/loader/ContentBuild.java
new file mode 100644 (file)
index 0000000..bd8d4f8
--- /dev/null
@@ -0,0 +1,129 @@
+package de.laktatnebel.product.websitegenerator.loader;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.List;
+
+/**
+ * Orchestriert den Markdown→SQL-Schritt, der VOR dem normalen
+ * db/articles/-Lauf im build.sh ausgeführt wird.
+ *
+ * Ablauf:
+ *   1. content/blog/ einlesen (ContentLoader)
+ *   2. pro Artikel SQL erzeugen (SqlEmitter)
+ *   3. SQL nach db/articles/_generated/ schreiben
+ *
+ * Bei Slug-Konflikt mit handgeschriebenem db/articles/*.sql gewinnt
+ * das manuelle File: der betroffene Artikel wird übersprungen.
+ */
+public final class ContentBuild {
+
+    private static final Logger log = LoggerFactory.getLogger(ContentBuild.class);
+    private static final String GENERATED_DIRNAME = "_generated";
+
+    private final ContentLoader loader;
+    private final SqlEmitter    emitter;
+
+    public ContentBuild() {
+        this.loader  = new ContentLoader(true); // "im Zweifel generieren"
+        this.emitter = new SqlEmitter();
+    }
+
+    /**
+     * @param contentRoot z.B. Path.of("content")
+     * @param articlesDir z.B. Path.of("db/articles")
+     * @param schema      Schema-Name (nur für Logging; SQL nutzt :"schema")
+     * @param checkOnly   true = nur Lint, keine Dateien schreiben
+     * @return Anzahl erzeugter SQL-Dateien
+     */
+    public int run(Path contentRoot, Path articlesDir, String schema, boolean checkOnly)
+            throws IOException {
+
+        List<LoadedArticle> articles = loader.loadBlogArticles(contentRoot);
+        if (articles.isEmpty()) return 0;
+
+        Path generatedDir = articlesDir.resolve(GENERATED_DIRNAME);
+        if (!checkOnly) {
+            Files.createDirectories(generatedDir);
+            cleanGenerated(generatedDir);
+        }
+
+        int written = 0;
+        for (LoadedArticle a : articles) {
+            if (manualFileExists(articlesDir, a.slug())) {
+                log.warn("⚠ {}: manuelles SQL-File existiert – Markdown übersprungen (SQL gewinnt)",
+                        a.slug());
+                continue;
+            }
+            printLint(a);
+            if (!checkOnly) {
+                String sql = emitter.emit(a);
+                Path out = generatedDir.resolve(filename(a.slug()));
+                Files.writeString(out, sql);
+                log.info("  → {}", out);
+                written++;
+            }
+        }
+
+        if (checkOnly) {
+            log.info("Lint abgeschlossen: {} Artikel geprüft (keine Dateien geschrieben).",
+                    articles.size());
+        } else {
+            log.info("{} SQL-Datei(en) erzeugt in {}", written, generatedDir);
+        }
+        return written;
+    }
+
+    private void printLint(LoadedArticle a) {
+        log.info("  Slug:           {}", a.slug());
+        log.info("  Titel:          \"{}\"", a.title());
+        if (a.subtitle() != null)  log.info("  Subtitle:       \"{}\"", a.subtitle());
+        log.info("  Excerpt:        {}",
+                a.excerpt() == null ? "(keiner)" : "(" + a.excerpt().length() + " Zeichen)");
+        if (!a.tags().isEmpty())   log.info("  Tags:           {}", String.join(", ", a.tags()));
+        if (!a.heroImageFiles().isEmpty()) {
+            log.info("  Hero-Bilder:    {} ({})", a.heroImageFiles().size(),
+                    String.join(", ", a.heroImageFiles()));
+            log.info("  hero_style:     {}", a.heroStyle() != null ? a.heroStyle() : "NULL (fix)");
+        }
+        if (a.cardImageFile() != null) log.info("  Card:           {}", a.cardImageFile());
+        a.galleryGroups().forEach((g, files) ->
+                log.info("  Galerie {}:      {}", g, String.join(", ", files)));
+        log.info("  Veröffentlichen: {}", a.publishAt());
+        if (a.unpublishAt() != null) log.info("  Ablaufen:       {}", a.unpublishAt());
+        if (a.authorSlug() != null)  log.info("  Autor:          {}", a.authorSlug());
+        log.info("  Bilder (embedded): {}", a.embeddedImages().size());
+        if (a.metaTitle() != null)   log.info("  meta_title:     {}", a.metaTitle());
+        if (a.metaDesc()  != null)   log.info("  meta_desc:      {}…",
+                a.metaDesc().substring(0, Math.min(50, a.metaDesc().length())));
+        if (a.canonicalUrl() != null) log.info("  canonical_url:  {}", a.canonicalUrl());
+    }
+
+    private boolean manualFileExists(Path articlesDir, String slug) throws IOException {
+        if (!Files.isDirectory(articlesDir)) return false;
+        // Bestehende Files haben Präfix-Nummern (z.B. 110_blog_warm-up...).
+        // Wir matchen auf das Slug-Suffix im Dateinamen.
+        String slugPart = slug.replace('/', '_');
+        try (DirectoryStream<Path> files = Files.newDirectoryStream(articlesDir, "*.sql")) {
+            for (Path f : files) {
+                String name = f.getFileName().toString();
+                if (name.contains(slugPart) || name.equals(filename(slug))) return true;
+            }
+        }
+        return false;
+    }
+
+    private void cleanGenerated(Path generatedDir) throws IOException {
+        try (DirectoryStream<Path> files = Files.newDirectoryStream(generatedDir, "*.sql")) {
+            for (Path f : files) Files.delete(f);
+        }
+    }
+
+    /** slug "blog/laufen-im-winter" → "blog_laufen-im-winter.sql" */
+    private String filename(String slug) {
+        return slug.replace('/', '_') + ".sql";
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/loader/ContentLoader.java b/src/main/java/de/laktatnebel/product/websitegenerator/loader/ContentLoader.java
new file mode 100644 (file)
index 0000000..49cba74
--- /dev/null
@@ -0,0 +1,338 @@
+package de.laktatnebel.product.websitegenerator.loader;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Liest content/blog/&lt;slug&gt;/artikel.md Dateien ein und erzeugt
+ * {@link LoadedArticle}-Records.
+ *
+ * Bild-Klassifizierung erfolgt strikt per Unterstrich-Präfix:
+ *   hero_&lt;slug&gt;_&lt;N&gt;.ext, card_&lt;slug&gt;.ext,
+ *   gallery_&lt;G&gt;_&lt;slug&gt;_&lt;N&gt;.ext
+ * Alles andere ist embedded (via Markdown-Referenz).
+ */
+public final class ContentLoader {
+
+    private static final Logger log = LoggerFactory.getLogger(ContentLoader.class);
+    private static final int EXCERPT_MAX_CHARS = 160;
+
+    private static final Pattern FRONTMATTER_LINE =
+            Pattern.compile("^([A-Za-z_][A-Za-z_]*):\\s*(.*)$");
+    private static final Pattern IMAGE_REF =
+            Pattern.compile("!\\[([^\\]]*)\\]\\(([^)]+)\\)");
+    private static final Pattern ITALIC_LINE =
+            Pattern.compile("^\\*([^*]+)\\*\\s*$");
+
+    private static final List<String> IMAGE_EXTENSIONS = List.of("jpg", "jpeg", "png", "webp");
+
+    private static final List<DateTimeFormatter> DATE_FORMATS = List.of(
+            DateTimeFormatter.ofPattern("yyyy-MM-dd"),
+            DateTimeFormatter.ofPattern("dd.MM.yyyy")
+    );
+
+    private final boolean autoExcerpt;
+
+    public ContentLoader(boolean autoExcerpt) {
+        this.autoExcerpt = autoExcerpt;
+    }
+
+    public List<LoadedArticle> loadBlogArticles(Path contentRoot) throws IOException {
+        Path blogDir = contentRoot.resolve("blog");
+        if (!Files.isDirectory(blogDir)) {
+            log.info("Kein content/blog/ Verzeichnis – überspringe Markdown-Loader.");
+            return List.of();
+        }
+
+        List<LoadedArticle> articles = new ArrayList<>();
+        Set<String> seenSlugs = new HashSet<>();
+
+        try (DirectoryStream<Path> dirs = Files.newDirectoryStream(blogDir, Files::isDirectory)) {
+            for (Path articleDir : dirs) {
+                Path md = articleDir.resolve("artikel.md");
+                if (!Files.isRegularFile(md)) {
+                    log.warn("✗ {}: keine artikel.md gefunden – übersprungen", articleDir.getFileName());
+                    continue;
+                }
+                try {
+                    LoadedArticle article = parse(articleDir, md);
+                    if (article == null) continue;
+                    if (!seenSlugs.add(article.slug())) {
+                        log.error("✗ {}: Slug-Duplikat – übersprungen", article.slug());
+                        continue;
+                    }
+                    articles.add(article);
+                } catch (Exception e) {
+                    log.error("✗ {}: {}", articleDir.getFileName(), e.getMessage());
+                }
+            }
+        }
+        return articles;
+    }
+
+    // ── Parsing ──────────────────────────────────────────────────
+
+    private LoadedArticle parse(Path articleDir, Path mdFile) throws IOException {
+        String folderSlug = articleDir.getFileName().toString();
+        List<String> lines = Files.readAllLines(mdFile);
+
+        // 1. Frontmatter: läuft bis zur ersten Leerzeile
+        //    Zeilen mit # am Anfang sind Kommentare (ignoriert)
+        Map<String, String> fm = new LinkedHashMap<>();
+        int bodyStart = 0;
+        boolean foundBlank = false;
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i);
+            if (line.isBlank()) {
+                bodyStart = i + 1;
+                foundBlank = true;
+                break;
+            }
+            if (line.startsWith("#")) continue; // Frontmatter-Kommentar
+            Matcher m = FRONTMATTER_LINE.matcher(line);
+            if (m.matches()) {
+                fm.put(m.group(1).trim().toLowerCase(), m.group(2).trim());
+            }
+        }
+        if (!foundBlank) bodyStart = 0; // kein Frontmatter-Block
+        List<String> body = lines.subList(bodyStart, lines.size());
+
+        // 2. Titel (erste # -Zeile)
+        String title = null;
+        int titleIdx = -1;
+        for (int i = 0; i < body.size(); i++) {
+            String l = body.get(i).trim();
+            if (l.startsWith("# ")) { title = l.substring(2).trim(); titleIdx = i; break; }
+        }
+        if (title == null || title.isEmpty()) {
+            log.error("✗ {}: artikel.md hat keine #-Überschrift – übersprungen", folderSlug);
+            return null;
+        }
+
+        // 3. Subtitle (erste kursive Zeile direkt nach Titel)
+        String subtitle = null;
+        for (int i = titleIdx + 1; i < body.size(); i++) {
+            String l = body.get(i).trim();
+            if (l.isEmpty()) continue;
+            if (l.startsWith("##")) break;
+            Matcher m = ITALIC_LINE.matcher(l);
+            if (m.matches()) subtitle = m.group(1).trim();
+            break;
+        }
+
+        // 4. Body ab erstem ##
+        int contentStart = body.size();
+        for (int i = titleIdx + 1; i < body.size(); i++) {
+            if (body.get(i).trim().startsWith("##")) { contentStart = i; break; }
+        }
+        String bodyMd = String.join("\n", body.subList(contentStart, body.size())).trim();
+
+        // 5. Embedded Images aus Markdown-Referenzen
+        List<String> embedded = new ArrayList<>();
+        Matcher img = IMAGE_REF.matcher(String.join("\n", body));
+        while (img.find()) {
+            String path = img.group(2).trim();
+            if (!path.startsWith("http")) embedded.add(path);
+        }
+
+        // 6. Bild-Klassifizierung per Präfix
+        ImageClassification imgs = classifyImages(articleDir, folderSlug);
+
+        // 7. hero_style (YAML oder Defaultlogik)
+        String heroStyleFm = blankToNull(fm.get("hero_style"));
+        String heroStyle;
+        if (heroStyleFm != null) {
+            heroStyle = heroStyleFm;
+        } else {
+            heroStyle = (imgs.heroFiles.size() > 1) ? "slideshow" : null;
+        }
+
+        // 8. Metadaten aus Frontmatter
+        String slug = "blog/" + folderSlug;
+        String excerptFm = fm.get("excerpt");
+        String excerpt = (excerptFm != null && !excerptFm.isBlank())
+                ? excerptFm
+                : (autoExcerpt ? autoExcerpt(bodyMd) : null);
+        List<String> tags = parseTags(fm.get("tags"));
+        String authorSlug = blankToNull(fm.get("author"));
+        LocalDate publishAt = parseDateWithFallback(fm.get("publish"), mdFile);
+        LocalDate unpublishAt = parseDateOptional(fm.get("unpublish"), folderSlug);
+
+        return new LoadedArticle(
+                slug, LoadedArticle.DEFAULT_PARENT,
+                title, bodyMd,
+                subtitle, excerpt, publishAt, unpublishAt,
+                authorSlug, LoadedArticle.DEFAULT_CATEGORY, tags,
+                imgs.heroFiles,
+                heroStyle,
+                imgs.cardFile,
+                imgs.galleryGroups,
+                blankToNull(fm.get("meta_title")),
+                blankToNull(fm.get("meta_desc")),
+                blankToNull(fm.get("canonical_url")),
+                embedded,
+                articleDir.toAbsolutePath().toString()
+        );
+    }
+
+    // ── Bild-Klassifizierung ─────────────────────────────────────
+
+    private record ImageClassification(
+            List<String> heroFiles,
+            String cardFile,
+            Map<Integer, List<String>> galleryGroups
+    ) {}
+
+    private ImageClassification classifyImages(Path dir, String folderSlug) throws IOException {
+        TreeMap<Integer, String> heroByN = new TreeMap<>();
+        String cardFile = null;
+        Map<Integer, TreeMap<Integer, String>> galleryByGN = new TreeMap<>();
+
+        try (DirectoryStream<Path> files = Files.newDirectoryStream(dir, Files::isRegularFile)) {
+            for (Path f : files) {
+                String name = f.getFileName().toString();
+                String lower = name.toLowerCase();
+
+                String ext = null;
+                for (String e : IMAGE_EXTENSIONS) {
+                    if (lower.endsWith("." + e)) { ext = e; break; }
+                }
+                if (ext == null) continue;
+
+                String nameWithoutExt = name.substring(0, name.length() - ext.length() - 1);
+                String[] parts = nameWithoutExt.split("_", -1);
+
+                if (parts.length == 3 && parts[0].equalsIgnoreCase("hero")) {
+                    String fileSlug = parts[1];
+                    String nStr = parts[2];
+                    if (!fileSlug.equals(folderSlug)) {
+                        log.warn("⚠ {}: Slug '{}' ≠ Ordner '{}' – ignoriert", name, fileSlug, folderSlug);
+                        continue;
+                    }
+                    if (!isNumeric(nStr)) {
+                        log.warn("⚠ {}: Hero-N '{}' nicht numerisch – ignoriert", name, nStr);
+                        continue;
+                    }
+                    heroByN.put(Integer.parseInt(nStr), name);
+
+                } else if (parts.length == 2 && parts[0].equalsIgnoreCase("card")) {
+                    String fileSlug = parts[1];
+                    if (!fileSlug.equals(folderSlug)) {
+                        log.warn("⚠ {}: Slug '{}' ≠ Ordner '{}' – ignoriert", name, fileSlug, folderSlug);
+                        continue;
+                    }
+                    cardFile = name;
+
+                } else if (parts.length == 4 && parts[0].equalsIgnoreCase("gallery")) {
+                    String gStr = parts[1];
+                    String fileSlug = parts[2];
+                    String nStr = parts[3];
+                    if (!isNumeric(gStr) || !isNumeric(nStr)) {
+                        log.warn("⚠ {}: Gallery G/N nicht numerisch – ignoriert", name);
+                        continue;
+                    }
+                    if (!fileSlug.equals(folderSlug)) {
+                        log.warn("⚠ {}: Slug '{}' ≠ Ordner '{}' – ignoriert", name, fileSlug, folderSlug);
+                        continue;
+                    }
+                    int g = Integer.parseInt(gStr);
+                    int n = Integer.parseInt(nStr);
+                    galleryByGN.computeIfAbsent(g, k -> new TreeMap<>()).put(n, name);
+
+                } else if (parts[0].equalsIgnoreCase("hero")
+                        || parts[0].equalsIgnoreCase("card")
+                        || parts[0].equalsIgnoreCase("gallery")) {
+                    log.warn("⚠ {}: Reserviertes Präfix, unvollständiges Muster – ignoriert", name);
+                }
+                // else: embedded image, tracked via Markdown-Referenz
+            }
+        }
+
+        if (!heroByN.isEmpty() && !heroByN.containsKey(1)) {
+            log.warn("⚠ {}: Hero-Bilder ohne _1 (kleinste Nr: {}) – bitte prüfen",
+                    folderSlug, heroByN.firstKey());
+        }
+
+        List<String> heroFiles = new ArrayList<>(heroByN.values());
+
+        Map<Integer, List<String>> galleryGroups = new LinkedHashMap<>();
+        for (Map.Entry<Integer, TreeMap<Integer, String>> e : galleryByGN.entrySet()) {
+            galleryGroups.put(e.getKey(), new ArrayList<>(e.getValue().values()));
+        }
+
+        return new ImageClassification(heroFiles, cardFile, galleryGroups);
+    }
+
+    // ── Helpers ──────────────────────────────────────────────────
+
+    private List<String> parseTags(String raw) {
+        if (raw == null || raw.isBlank()) return List.of();
+        return Arrays.stream(raw.split(","))
+                .map(String::trim).filter(s -> !s.isEmpty())
+                .collect(Collectors.toList());
+    }
+
+    /** Parst Datum mit Fallback auf Datei-mtime (für publish:). */
+    private LocalDate parseDateWithFallback(String raw, Path mdFile) throws IOException {
+        LocalDate d = parseDateRaw(raw);
+        if (d != null) return d;
+        if (raw != null && !raw.isBlank())
+            log.warn("Datum \"{}\" nicht erkannt – nutze Datei-Zeitstempel", raw);
+        BasicFileAttributes attr = Files.readAttributes(mdFile, BasicFileAttributes.class);
+        return attr.lastModifiedTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+    }
+
+    /** Parst optionales Datum (für unpublish:); gibt null zurück wenn leer oder ungültig. */
+    private LocalDate parseDateOptional(String raw, String context) {
+        if (raw == null || raw.isBlank()) return null;
+        LocalDate d = parseDateRaw(raw);
+        if (d == null) log.warn("⚠ {}: Datum \"{}\" nicht erkannt – ignoriert", context, raw);
+        return d;
+    }
+
+    private LocalDate parseDateRaw(String raw) {
+        if (raw == null || raw.isBlank()) return null;
+        for (DateTimeFormatter fmt : DATE_FORMATS) {
+            try { return LocalDate.parse(raw.trim(), fmt); }
+            catch (Exception ignore) { }
+        }
+        return null;
+    }
+
+    private String autoExcerpt(String bodyMd) {
+        for (String para : bodyMd.split("\n\n")) {
+            String c = para.trim();
+            if (c.isEmpty() || c.startsWith("#") || c.startsWith("![")
+                    || c.startsWith("-") || c.startsWith("*")) continue;
+            c = c.replaceAll("[*_`]", "")
+                 .replaceAll("\\[([^\\]]+)\\]\\([^)]+\\)", "$1")
+                 .replaceAll("\\s+", " ").trim();
+            if (c.length() <= EXCERPT_MAX_CHARS) return c;
+            String cut = c.substring(0, EXCERPT_MAX_CHARS);
+            int sp = cut.lastIndexOf(' ');
+            return (sp > 0 ? cut.substring(0, sp) : cut) + "…";
+        }
+        return null;
+    }
+
+    private boolean isNumeric(String s) {
+        if (s == null || s.isEmpty()) return false;
+        for (char c : s.toCharArray()) if (!Character.isDigit(c)) return false;
+        return true;
+    }
+
+    private String blankToNull(String s) {
+        return (s == null || s.isBlank()) ? null : s.trim();
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/loader/LoadedArticle.java b/src/main/java/de/laktatnebel/product/websitegenerator/loader/LoadedArticle.java
new file mode 100644 (file)
index 0000000..4a48556
--- /dev/null
@@ -0,0 +1,71 @@
+package de.laktatnebel.product.websitegenerator.loader;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Geparste, normalisierte Repräsentation eines Markdown-Artikels
+ * aus content/blog/&lt;slug&gt;/artikel.md.
+ *
+ * Pflichtfelder sind non-null (Loader validiert), optionale können null sein.
+ */
+public record LoadedArticle(
+        // ── Identität ─────────────────────────────────────────────
+        String       slug,           // z.B. "blog/laufen-im-winter"
+        String       parentSlug,     // z.B. "blog" (für pages.parent_id-Subquery)
+
+        // ── page_content Pflicht ──────────────────────────────────
+        String       title,          // aus erster #-Zeile
+        String       body,           // alles ab erstem ## (Markdown)
+
+        // ── page_content optional ─────────────────────────────────
+        String       subtitle,       // *Italic*-Zeile direkt unter Titel, oder null
+        String       excerpt,        // Frontmatter oder auto-generiert
+        LocalDate    publishAt,      // Frontmatter "publish:" oder Datei-mtime
+        LocalDate    unpublishAt,    // Frontmatter "unpublish:", oder null
+        String       authorSlug,     // Frontmatter "author:", oder null (→ Default id=1)
+        String       categorySlug,   // immer "blog" für Blog-Artikel
+        List<String> tags,           // Frontmatter "tags:", oder leere Liste
+
+        // ── Hero-Bilder (sortiert nach N) ─────────────────────────
+        List<String> heroImageFiles, // hero_<slug>_1.jpg, hero_<slug>_2.jpg, ...
+        // hero_style: null = fix, "carousel", "slideshow", "random"
+        String       heroStyle,
+
+        // ── Card-Bild ─────────────────────────────────────────────
+        String       cardImageFile,  // card_<slug>.jpg, oder null (Fallback auf Hero-1)
+
+        // ── Galerie: G → sortierte Dateinamen-Liste (nach N) ─────
+        Map<Integer, List<String>> galleryGroups,
+
+        // ── SEO ───────────────────────────────────────────────────
+        String       metaTitle,      // Frontmatter "meta_title:", oder null
+        String       metaDesc,       // Frontmatter "meta_desc:", oder null
+        String       canonicalUrl,   // Frontmatter "canonical_url:", oder null
+
+        // ── Eingebettete Bilder (für Lint) ────────────────────────
+        List<String> embeddedImages,
+
+        // ── Quelle (Logging/Lint) ─────────────────────────────────
+        String       sourceDir
+) {
+    /** Konstanter Template-Typ für Blog-Artikel. */
+    public static final String TEMPLATE = "article";
+    /** Konstanter Status. */
+    public static final String STATUS = "published";
+    /** Default-Kategorie für Blog-Artikel. */
+    public static final String DEFAULT_CATEGORY = "blog";
+    /** Default-Parent-Seite. */
+    public static final String DEFAULT_PARENT = "blog";
+
+    /** Erster Hero-Dateiname (für pages.hero_media_id / content_media position=1). */
+    public String primaryHeroFile() {
+        return heroImageFiles != null && !heroImageFiles.isEmpty() ? heroImageFiles.get(0) : null;
+    }
+
+    /** Effektives Card-Bild: explizites card_* oder Fallback auf Hero-1. */
+    public String effectiveCardFile() {
+        return cardImageFile != null ? cardImageFile : primaryHeroFile();
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/loader/SqlEmitter.java b/src/main/java/de/laktatnebel/product/websitegenerator/loader/SqlEmitter.java
new file mode 100644 (file)
index 0000000..00982e5
--- /dev/null
@@ -0,0 +1,182 @@
+package de.laktatnebel.product.websitegenerator.loader;
+
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Übersetzt einen {@link LoadedArticle} in SQL – im exakten Stil der
+ * bestehenden handgeschriebenen Files unter db/articles/:
+ *
+ *   - SET search_path TO :"schema";
+ *   - positionale INSERTs (VALUES (DEFAULT, ...))
+ *   - Verknüpfung über (SELECT id FROM pages WHERE slug='...')
+ *
+ * Damit fügt sich das generierte SQL nahtlos in den bestehenden
+ * build.sh-Ablauf (psql -v schema=... -f datei.sql) ein.
+ *
+ * Reine Funktion ohne Seiteneffekte.
+ */
+public final class SqlEmitter {
+
+    private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    public String emit(LoadedArticle a) {
+        StringBuilder sb = new StringBuilder();
+
+        sb.append("-- ============================================================\n");
+        sb.append("-- AUTO-GENERIERT aus ").append(a.sourceDir()).append("\n");
+        sb.append("-- NICHT HÄNDISCH BEARBEITEN – Änderungen in artikel.md vornehmen.\n");
+        sb.append("-- ============================================================\n");
+        sb.append("\nSET search_path TO :\"schema\";\n\n");
+
+        String dateLit = a.publishAt() != null ? q(a.publishAt().format(DATE)) : "now()";
+        String unpublishLit = a.unpublishAt() != null ? q(a.unpublishAt().format(DATE)) : "NULL";
+
+        // ── pages ────────────────────────────────────────────────
+        // Spalten: id, slug, parent_id, template, sort_order, in_sitemap,
+        //          locked_category, external_url, hero_media_id, created_at, updated_at
+        sb.append("INSERT INTO :\"schema\".pages VALUES (\n");
+        sb.append("    DEFAULT,\n");
+        sb.append("    ").append(q(a.slug())).append(",\n");
+        sb.append("    (SELECT id FROM :\"schema\".pages WHERE slug=")
+          .append(q(a.parentSlug())).append("),\n");
+        sb.append("    ").append(q(LoadedArticle.TEMPLATE)).append(",\n");
+        sb.append("    0, true, NULL, NULL,\n");
+        sb.append("    ").append(heroMediaSubquery(a.primaryHeroFile())).append(",\n");
+        sb.append("    ").append(dateLit).append(", ").append(dateLit).append("\n");
+        sb.append(");\n\n");
+
+        // ── page_content ─────────────────────────────────────────
+        // Spalten: id, page_id, author_id, category_id, title, subtitle,
+        //          section_label, excerpt, body, body_secondary,
+        //          hero_media_id, hero_style, card_media_id,
+        //          meta_title, meta_desc, canonical_url,
+        //          status, publish_at, unpublish_at, created_at, updated_at
+        sb.append("INSERT INTO :\"schema\".page_content VALUES (\n");
+        sb.append("    DEFAULT,\n");
+        sb.append("    (SELECT id FROM :\"schema\".pages WHERE slug=")
+          .append(q(a.slug())).append("),\n");
+        if (a.authorSlug() != null) {
+            sb.append("    (SELECT id FROM :\"schema\".authors WHERE slug=")
+              .append(q(a.authorSlug())).append("),\n");
+        } else {
+            sb.append("    1,\n");
+        }
+        sb.append("    (SELECT id FROM :\"schema\".categories WHERE slug=")
+          .append(q(a.categorySlug())).append("),\n");
+        sb.append("    ").append(q(a.title())).append(",\n");
+        sb.append("    ").append(q(a.subtitle())).append(",\n");
+        sb.append("    NULL,\n");                              // section_label
+        sb.append("    ").append(q(a.excerpt())).append(",\n");
+        sb.append("    ").append(dollar(a.body())).append(",\n");
+        sb.append("    '',\n");                                // body_secondary
+        sb.append("    ").append(heroMediaSubquery(a.primaryHeroFile())).append(",\n");
+        sb.append("    ").append(q(a.heroStyle())).append(",\n");
+        sb.append("    ").append(heroMediaSubquery(a.effectiveCardFile())).append(",\n");
+        sb.append("    ").append(q(a.metaTitle())).append(",\n");
+        sb.append("    ").append(q(a.metaDesc())).append(",\n");
+        sb.append("    ").append(q(a.canonicalUrl())).append(",\n");
+        sb.append("    ").append(q(LoadedArticle.STATUS)).append(",\n");
+        sb.append("    ").append(dateLit).append(", ").append(unpublishLit).append(", ")
+          .append(dateLit).append(", ").append(dateLit).append("\n");
+        sb.append(");\n\n");
+
+        // ── content_pages (Kategorie-Zuordnung) ──────────────────
+        sb.append("INSERT INTO :\"schema\".content_pages VALUES (\n");
+        sb.append("    (SELECT id FROM :\"schema\".pages WHERE slug=")
+          .append(q(a.slug())).append("),\n");
+        sb.append("    (SELECT id FROM :\"schema\".categories WHERE slug=")
+          .append(q(a.categorySlug())).append(")\n");
+        sb.append(");\n\n");
+
+        // ── content_tags ─────────────────────────────────────────
+        for (String tag : a.tags()) {
+            sb.append("INSERT INTO :\"schema\".content_tags VALUES (\n");
+            sb.append("    (SELECT id FROM :\"schema\".pages WHERE slug=")
+              .append(q(a.slug())).append("),\n");
+            sb.append("    (SELECT id FROM :\"schema\".tags WHERE slug=")
+              .append(q(tag)).append(")\n");
+            sb.append(");\n");
+        }
+        if (!a.tags().isEmpty()) sb.append("\n");
+
+        // ── content_media: Heroes (role='hero') ──────────────────
+        List<String> heroes = a.heroImageFiles();
+        if (!heroes.isEmpty()) {
+            for (int i = 0; i < heroes.size(); i++) {
+                sb.append("INSERT INTO :\"schema\".content_media VALUES (\n");
+                sb.append("    (SELECT id FROM :\"schema\".pages WHERE slug=")
+                  .append(q(a.slug())).append("),\n");
+                sb.append("    (SELECT id FROM :\"schema\".media WHERE filename LIKE ")
+                  .append(q(stripExt(basename(heroes.get(i))) + ".%"))
+                  .append(" LIMIT 1),\n");
+                sb.append("    ").append(i + 1).append(",\n");
+                sb.append("    'hero'\n");
+                sb.append(");\n");
+            }
+            sb.append("\n");
+        }
+
+        // ── content_media: Gallery (role='body') ─────────────────
+        if (!a.galleryGroups().isEmpty()) {
+            int pos = 1;
+            for (Map.Entry<Integer, List<String>> group : a.galleryGroups().entrySet()) {
+                for (String galleryFile : group.getValue()) {
+                    sb.append("INSERT INTO :\"schema\".content_media VALUES (\n");
+                    sb.append("    (SELECT id FROM :\"schema\".pages WHERE slug=")
+                      .append(q(a.slug())).append("),\n");
+                    sb.append("    (SELECT id FROM :\"schema\".media WHERE filename LIKE ")
+                      .append(q(stripExt(basename(galleryFile)) + ".%"))
+                      .append(" LIMIT 1),\n");
+                    sb.append("    ").append(pos++).append(",\n");
+                    sb.append("    'body'\n");
+                    sb.append(");\n");
+                }
+            }
+            sb.append("\n");
+        }
+
+        return sb.toString();
+    }
+
+    // ── Helpers ──────────────────────────────────────────────────
+
+    /** Subquery für media-ID per filename-LIKE, oder NULL wenn kein Dateiname. */
+    private String heroMediaSubquery(String filename) {
+        if (filename == null) return "NULL";
+        return "(SELECT id FROM :\"schema\".media WHERE filename LIKE "
+                + q(stripExt(basename(filename)) + ".%") + " LIMIT 1)";
+    }
+
+    /** String-Literal mit Single-Quote-Escaping, oder NULL. */
+    private String q(String s) {
+        if (s == null) return "NULL";
+        return "'" + s.replace("'", "''") + "'";
+    }
+
+    /**
+     * Dollar-Quoted für mehrzeiligen Body.
+     * Wählt sicheres Tag falls Body die Standard-Marke enthält.
+     */
+    private String dollar(String s) {
+        if (s == null) return "NULL";
+        String tag = "$md$";
+        if (s.contains(tag)) {
+            int n = 1;
+            while (s.contains("$md" + n + "$")) n++;
+            tag = "$md" + n + "$";
+        }
+        return tag + s + tag;
+    }
+
+    private String basename(String path) {
+        int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
+        return (slash >= 0) ? path.substring(slash + 1) : path;
+    }
+
+    private String stripExt(String name) {
+        int dot = name.lastIndexOf('.');
+        return (dot > 0) ? name.substring(0, dot) : name;
+    }
+}