bin
target
.dbeaver/
+*.tar.gz
--- /dev/null
+# 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 .
+# ═══════════════════════════════════════════════════════════
+
+# 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.
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;
* 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 {
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);
}
}
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 {
log.warn("{} Fehler aufgetreten – siehe Log", result.errorCount());
System.exit(2);
}
-
} catch (Exception e) {
log.error("Build fehlgeschlagen: {}", e.getMessage(), e);
System.exit(1);
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;
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();
--- /dev/null
+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";
+ }
+}
--- /dev/null
+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/<slug>/artikel.md Dateien ein und erzeugt
+ * {@link LoadedArticle}-Records.
+ *
+ * Bild-Klassifizierung erfolgt strikt per Unterstrich-Präfix:
+ * hero_<slug>_<N>.ext, card_<slug>.ext,
+ * gallery_<G>_<slug>_<N>.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();
+ }
+}
--- /dev/null
+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/<slug>/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();
+ }
+}
--- /dev/null
+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;
+ }
+}