From: Claude Code AI Date: Thu, 25 Jun 2026 08:43:07 +0000 (+0200) Subject: Markdown-Blog-Loader vollständig implementiert (Schritt 1–3 laut SPEC) X-Git-Url: https://git.laktatnebel.de/?a=commitdiff_plain;h=0a8008ed93addff9e4d9dd4f22eb60d8289b9fc9;p=websitegenerator.git Markdown-Blog-Loader vollständig implementiert (Schritt 1–3 laut SPEC) - 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 --- diff --git a/.gitignore b/.gitignore index bfc3202..0df8450 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ bin target .dbeaver/ +*.tar.gz diff --git a/SPEC.md b/SPEC.md new file mode 100644 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// +├── artikel.md +├── hero__1.jpg (optional, mehrere möglich für Carousel) +├── card_.jpg (optional) +├── gallery_1__1.jpg (optional, mehrere Galerien/Bilder) +└── beliebiger-name.jpg (embedded, im Markdown referenziert) +``` + +`` = Ordnername. Resultierender Seiten-Slug: `blog/`. + +## 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/` | +| 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__.` | hero_laufen-im-winter_1.jpg | +| Card | `card_.` | card_laufen-im-winter.jpg | +| Gallery | `gallery___.` | gallery_1_laufen-im-winter_2.jpg | +| Embedded | `.` | 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` _ `` _ `` . ext → N = Sortierreihenfolge +- Card: `card` _ `` . ext +- Gallery: `gallery` _ `` _ `` _ `` . 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__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_.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>), 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. diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/Main.java b/src/main/java/de/laktatnebel/product/websitegenerator/Main.java index 38f17e5..63a7729 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/Main.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/Main.java @@ -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); diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java index dd959a6..237df04 100644 --- a/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java +++ b/src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java @@ -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 blog = raw.containsKey("blog") ? (Map) 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 index 0000000..bd8d4f8 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/loader/ContentBuild.java @@ -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 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 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 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 index 0000000..49cba74 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/loader/ContentLoader.java @@ -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/<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 IMAGE_EXTENSIONS = List.of("jpg", "jpeg", "png", "webp"); + + private static final List 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 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 articles = new ArrayList<>(); + Set seenSlugs = new HashSet<>(); + + try (DirectoryStream 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 lines = Files.readAllLines(mdFile); + + // 1. Frontmatter: läuft bis zur ersten Leerzeile + // Zeilen mit # am Anfang sind Kommentare (ignoriert) + Map 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 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 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 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 heroFiles, + String cardFile, + Map> galleryGroups + ) {} + + private ImageClassification classifyImages(Path dir, String folderSlug) throws IOException { + TreeMap heroByN = new TreeMap<>(); + String cardFile = null; + Map> galleryByGN = new TreeMap<>(); + + try (DirectoryStream 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 heroFiles = new ArrayList<>(heroByN.values()); + + Map> galleryGroups = new LinkedHashMap<>(); + for (Map.Entry> e : galleryByGN.entrySet()) { + galleryGroups.put(e.getKey(), new ArrayList<>(e.getValue().values())); + } + + return new ImageClassification(heroFiles, cardFile, galleryGroups); + } + + // ── Helpers ────────────────────────────────────────────────── + + private List 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 index 0000000..4a48556 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/loader/LoadedArticle.java @@ -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/<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 tags, // Frontmatter "tags:", oder leere Liste + + // ── Hero-Bilder (sortiert nach N) ───────────────────────── + List heroImageFiles, // hero__1.jpg, hero__2.jpg, ... + // hero_style: null = fix, "carousel", "slideshow", "random" + String heroStyle, + + // ── Card-Bild ───────────────────────────────────────────── + String cardImageFile, // card_.jpg, oder null (Fallback auf Hero-1) + + // ── Galerie: G → sortierte Dateinamen-Liste (nach N) ───── + Map> 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 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 index 0000000..00982e5 --- /dev/null +++ b/src/main/java/de/laktatnebel/product/websitegenerator/loader/SqlEmitter.java @@ -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 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> 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; + } +}