using System.Globalization; using System.Text.RegularExpressions; using HtmlAgilityPack; namespace wp2ghost { // ============================================================================= // HtmlGhostConverter — WordPress HTML zu Ghost-kompatibles HTML konvertieren // ============================================================================= public static class HtmlGhostConverter { private static readonly Dictionary WpClassMap = new(StringComparer.OrdinalIgnoreCase) { // Block-Elemente → Ghost-Äquivalent oder null (Klasse entfernen) { "wp-block-paragraph", null }, { "wp-block-heading", null }, { "wp-block-list", null }, { "wp-block-quote", null }, { "wp-block-code", null }, { "wp-block-separator", null }, { "wp-block-group", null }, { "wp-block-columns", null }, { "wp-block-column", null }, { "wp-block-freeform", null }, { "wp-block-verse", null }, { "wp-block-preformatted", null }, // Ghost-Kompatible Klassen { "wp-block-image", "kg-card kg-image-card" }, { "wp-block-gallery", "kg-card kg-gallery-card" }, { "wp-block-pullquote", "kg-card kg-pullquote" }, { "wp-block-embed", "kg-card kg-embed-card" }, { "wp-block-embed-wrapper", "kg-card kg-embed-card" }, { "wp-block-video", "kg-card kg-embed-card" }, { "wp-block-audio", "kg-card kg-embed-card" }, { "wp-block-cover", "kg-card kg-header-card" }, { "wp-block-media-text", "kg-card kg-width-wide" }, { "wp-block-table", "kg-card kg-table-card" }, { "wp-block-button", "kg-card kg-button-card" }, { "wp-block-buttons", "kg-card kg-button-card" }, { "wp-block-file", "kg-card kg-file-card" }, // Sub-Elemente { "wp-block-button__link", "gh-btn" }, { "wp-block-button__link__inline", "gh-btn" }, { "wp-element-button", "gh-btn" }, { "has-text-align-center", "kg-width-wide" }, { "has-text-align-left", "" }, { "has-text-align-right", "" }, { "is-style-wide", "kg-width-wide" }, { "is-style-full", "kg-width-wide" }, { "is-style-large", "kg-width-wide" }, // Theme-spezifisch { "wp-block-post-title", null }, { "wp-block-post-content", null }, { "wp-block-site-title", null }, { "wp-block-site-tagline", null }, { "wp-block-query", null }, { "wp-block-comments", null }, { "wp-block-comment-template", null }, { "wp-block-template-part", null }, // CoBlocks { "wp-block-coblocks-gallery-stacked", "kg-card kg-gallery-card" }, { "wp-block-coblocks-gallery-collage", "kg-card kg-gallery-card" }, { "wp-block-coblocks-gallery-masonry", "kg-card kg-gallery-card" }, { "wp-block-coblocks-gallery-thumbnails", "kg-card kg-gallery-card" }, { "wp-block-coblocks-alert", "kg-card kg-alert-card" }, { "wp-block-coblocks-accordion", "kg-card kg-accordion-card" }, { "wp-block-coblocks-accordion-item", "kg-accordion-item" }, { "wp-block-coblocks-row", "kg-width-wide" }, { "wp-block-coblocks-column", "" }, { "wp-block-coblocks-click-to-tweet", "kg-card kg-click-to-tweet" }, { "wp-block-coblocks-social-profiles", "kg-card kg-social-link" }, // Jetpack { "wp-block-jetpack-tiled-gallery", "kg-card kg-gallery-card" }, { "wp-block-jetpack-slideshow", "kg-card kg-gallery-card" }, { "wp-block-jetpack-story", "kg-card kg-gallery-card" }, }; // Ghost-Galerien fassen maximal 9 Bilder (3 pro Zeile) — größere WordPress-Galerien // werden in mehrere aufeinanderfolgende Ghost-Galerien aufgeteilt. private const int MaxGalleryImages = 9; private const int GalleryImagesPerRow = 3; // Liefert die echten Maße eines bereits heruntergeladenen Bildes (per src-URL) private static Func? _imageSizeResolver; private static int _ghostGalleryCount; private static int _skippedGalleryImages; private static int _captionedGalleryImages; public static string Convert(string html, Func? imageSizeResolver = null) { if (string.IsNullOrWhiteSpace(html)) return string.Empty; _imageSizeResolver = imageSizeResolver; _ghostGalleryCount = 0; _skippedGalleryImages = 0; _captionedGalleryImages = 0; var htmlDoc = new HtmlDocument(); htmlDoc.LoadHtml(html); RemoveJunkCaptions(htmlDoc); int brokenImages = RemoveBrokenImages(htmlDoc); if (brokenImages > 0) Console.WriteLine($" [Warnung] {brokenImages} Bild(er) mit blob:-Quelle entfernt (schon in WordPress defekt)"); int totalGalleries = 0; totalGalleries += ConvertClassicGalleries(htmlDoc); // Gutenberg Gallery totalGalleries += ConvertImageGalleries(htmlDoc, "//figure[contains(@class, 'wp-block-gallery')] | " + "//div[contains(@class, 'wp-block-gallery')] | " + "//ul[contains(@class, 'wp-block-gallery')]"); // Jetpack Tiled Gallery totalGalleries += ConvertImageGalleries(htmlDoc, "//div[contains(@class, 'wp-block-jetpack-tiled-gallery')] | " + "//figure[contains(@class, 'wp-block-jetpack-tiled-gallery')]"); // CoBlocks Gallery totalGalleries += ConvertImageGalleries(htmlDoc, "//figure[contains(@class, 'wp-block-coblocks-gallery-masonry')] | " + "//figure[contains(@class, 'wp-block-coblocks-gallery-stacked')] | " + "//figure[contains(@class, 'wp-block-coblocks-gallery-collage')] | " + "//figure[contains(@class, 'wp-block-coblocks-gallery-thumbnails')]"); if (totalGalleries > 0) { string split = _ghostGalleryCount > totalGalleries ? $" → {_ghostGalleryCount} Ghost-Galerien (max. {MaxGalleryImages} Bilder)" : ""; Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert{split}"); } if (_captionedGalleryImages > 0) Console.WriteLine($" [Gallery] {_captionedGalleryImages} Galerie-Bild(er) mit Unterschrift als Einzelbild ausgegeben"); if (_skippedGalleryImages > 0) Console.WriteLine($" [Warnung] {_skippedGalleryImages} Galerie-Bild(er) ohne gültige Bildquelle (data:) ausgelassen"); ConvertSingleImages(htmlDoc); ConvertFigures(htmlDoc); ConvertWpClasses(htmlDoc); AddTargetBlankToExternalLinks(htmlDoc); _imageSizeResolver = null; return htmlDoc.DocumentNode.OuterHtml; } // ========================================================================= // Bilder mit blob:-Quelle entfernen: der Upload ist in WordPress nie // abgeschlossen worden, es gibt keine Datei, die übernommen werden könnte. // ========================================================================= private static int RemoveBrokenImages(HtmlDocument htmlDoc) { var imgs = htmlDoc.DocumentNode.SelectNodes("//img[starts-with(@src, 'blob:')]"); if (imgs == null) return 0; int count = 0; foreach (var img in imgs.ToList()) { if (!string.IsNullOrEmpty(GetBestImageUrl(img)) && !GetBestImageUrl(img).StartsWith("blob:")) continue; // data-orig-file/srcset liefern noch eine echte Quelle // Umschließende Einzelbild-Figure mit entfernen, wenn sie nur dieses Bild enthält var figure = img.Ancestors("figure").FirstOrDefault(); HtmlNode toRemove = figure != null && figure.SelectNodes(".//img")?.Count == 1 ? figure : img; toRemove.Remove(); count++; } return count; } // ========================================================================= // Platzhalter-Unterschriften entfernen, die die Kamera/Handy-App automatisch // einträgt ("default") — sie gelten für kein Bild. // ========================================================================= private static void RemoveJunkCaptions(HtmlDocument htmlDoc) { var captions = htmlDoc.DocumentNode.SelectNodes("//figcaption"); if (captions == null) return; foreach (var caption in captions.ToList()) { string text = HtmlEntity.DeEntitize(caption.InnerText).Trim(); if (text.Equals("default", StringComparison.OrdinalIgnoreCase)) caption.Remove(); } } private static string? GetCaptionHtml(HtmlNode? figcaption) { if (figcaption == null || string.IsNullOrWhiteSpace(HtmlEntity.DeEntitize(figcaption.InnerText))) return null; return figcaption.InnerHtml.Trim(); } // ========================================================================= // Gallery-Typ 1: WordPress Classic Gallery // HTML: // ========================================================================= private static int ConvertClassicGalleries(HtmlDocument htmlDoc) { var galleryNodes = htmlDoc.DocumentNode.SelectNodes( "//div[contains(@class, 'gallery') and not(contains(@class, 'wp-block'))]" ); if (galleryNodes == null) return 0; int count = 0; foreach (var gallery in galleryNodes.ToList()) { var items = gallery.SelectNodes(".//figure[contains(@class, 'gallery-item')]"); if (items == null || items.Count == 0) continue; var images = new List<(HtmlNode Wrapper, string? Caption)>(); foreach (var item in items) { var img = item.SelectSingleNode(".//img"); if (img == null) continue; var wrapper = TryBuildGalleryImage(htmlDoc, img); if (wrapper != null) images.Add((wrapper, GetCaptionHtml(item.SelectSingleNode(".//figcaption")))); } if (images.Count == 0) continue; ReplaceWithGhostGalleries(htmlDoc, gallery, images); count++; } return count; } // ========================================================================= // Gallery-Typ 2-4: Gutenberg, Jetpack Tiled, CoBlocks — alle des Blocks // ========================================================================= private static int ConvertImageGalleries(HtmlDocument htmlDoc, string xpath) { var galleryNodes = htmlDoc.DocumentNode.SelectNodes(xpath); if (galleryNodes == null) return 0; int count = 0; foreach (var gallery in galleryNodes.ToList()) { var imgs = gallery.SelectNodes(".//img"); if (imgs == null || imgs.Count == 0) continue; var images = new List<(HtmlNode Wrapper, string? Caption)>(); foreach (var img in imgs) { var wrapper = TryBuildGalleryImage(htmlDoc, img); if (wrapper == null) continue; // Unterschrift des einzelnen Bildes: figcaption seiner eigenen Figure (nicht der Galerie) var imageFigure = img.Ancestors().TakeWhile(a => a != gallery).FirstOrDefault(a => a.Name == "figure"); images.Add((wrapper, GetCaptionHtml(imageFigure?.SelectSingleNode(".//figcaption")))); } if (images.Count == 0) continue; ReplaceWithGhostGalleries(htmlDoc, gallery, images); count++; } return count; } // ========================================================================= // Galerie-Helfer: Aufteilen, Unterschriften, Wide-Klasse, Bild-Wrapper, Zeilen // ========================================================================= /// /// Ersetzt eine WordPress-Galerie durch Ghost-Karten in der ursprünglichen Bildreihenfolge. /// Eine Ghost-Galerie hat nur EINE Unterschrift für alle Bilder; damit jede Unterschrift /// bei ihrem Bild bleibt, wird ein Bild mit Unterschrift als eigenes Einzelbild ausgegeben /// und die Bilder davor/danach bilden jeweils eigene Galerien (max. 9 Bilder). /// private static void ReplaceWithGhostGalleries(HtmlDocument htmlDoc, HtmlNode gallery, List<(HtmlNode Wrapper, string? Caption)> images) { var parent = gallery.ParentNode; if (parent == null) return; string? galleryCaption = TakeGalleryCaption(gallery); HtmlNode? lastGallery = null; var run = new List(); void FlushRun() { if (run.Count == 1) { parent.InsertBefore(BuildImageCard(htmlDoc, run[0], null), gallery); } else { foreach (var chunk in SplitGallery(run)) { var ghostGallery = htmlDoc.CreateElement("figure"); SetClasses(ghostGallery, "kg-card kg-gallery-card"); AddWideClass(ghostGallery, gallery); var container = htmlDoc.CreateElement("div"); container.AddClass("kg-gallery-container"); AppendGalleryRows(htmlDoc, container, chunk, GalleryImagesPerRow); ghostGallery.AppendChild(container); parent.InsertBefore(ghostGallery, gallery); lastGallery = ghostGallery; _ghostGalleryCount++; } } run.Clear(); } foreach (var (wrapper, caption) in images) { if (caption == null) { run.Add(wrapper); continue; } FlushRun(); parent.InsertBefore(BuildImageCard(htmlDoc, wrapper, caption), gallery); _captionedGalleryImages++; } FlushRun(); // Unterschrift der ganzen Galerie: an die letzte Teil-Galerie, sonst als Absatz dahinter if (galleryCaption != null) { if (lastGallery != null) { var newCaption = htmlDoc.CreateElement("figcaption"); newCaption.InnerHtml = galleryCaption; lastGallery.AppendChild(newCaption); lastGallery.AddClass("kg-card-hascaption"); } else { var paragraph = htmlDoc.CreateElement("p"); paragraph.InnerHtml = galleryCaption; parent.InsertBefore(paragraph, gallery); } } parent.RemoveChild(gallery); } // Unterschrift der ganzen Galerie: direktes figcaption-Kind (Gutenberg) oder ein // figcaption unmittelbar HINTER der Galerie (CoBlocks: class="blocks-gallery-caption"). private static string? TakeGalleryCaption(HtmlNode gallery) { var captionNode = gallery.SelectSingleNode("./figcaption"); if (captionNode == null) { var next = gallery.NextSibling; while (next != null && next.NodeType != HtmlNodeType.Element && string.IsNullOrWhiteSpace(next.InnerText)) next = next.NextSibling; if (next != null && next.Name == "figcaption") captionNode = next; } if (captionNode == null) return null; string? caption = GetCaptionHtml(captionNode); captionNode.Remove(); return caption; } private static HtmlNode BuildImageCard(HtmlDocument htmlDoc, HtmlNode galleryImageWrapper, string? captionHtml) { var figure = htmlDoc.CreateElement("figure"); SetClasses(figure, "kg-card kg-image-card"); var img = galleryImageWrapper.SelectSingleNode("./img")!; img.Remove(); figure.AppendChild(img); if (!string.IsNullOrEmpty(captionHtml)) { var caption = htmlDoc.CreateElement("figcaption"); caption.InnerHtml = captionHtml; figure.AppendChild(caption); figure.AddClass("kg-card-hascaption"); } return figure; } private static List> SplitGallery(List wrappers) { var chunks = new List>(); for (int i = 0; i < wrappers.Count;) { int remaining = wrappers.Count - i; int take = Math.Min(MaxGalleryImages, remaining); // Kein einzelnes Bild als letzte Galerie übrig lassen (10 → 8 + 2) if (remaining - take == 1) take--; chunks.Add(wrappers.GetRange(i, take)); i += take; } return chunks; } private static void AddWideClass(HtmlNode ghostGallery, HtmlNode originalGallery) { string origClass = originalGallery.GetAttributeValue("class", ""); if (origClass.Contains("is-style-wide") || origClass.Contains("alignwide")) ghostGallery.AddClass("kg-width-wide"); } private static HtmlNode? TryBuildGalleryImage(HtmlDocument htmlDoc, HtmlNode img) { string src = GetBestImageUrl(img); if (string.IsNullOrEmpty(src)) return null; // data:-Bilder (Platzhalter) lassen sich nicht als Galerie-Bild exportieren if (src.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) { _skippedGalleryImages++; return null; } return BuildGalleryImage(htmlDoc, img, src); } private static HtmlNode BuildGalleryImage(HtmlDocument htmlDoc, HtmlNode originalImg, string src) { var imgWrapper = htmlDoc.CreateElement("div"); imgWrapper.AddClass("kg-gallery-image"); var newImg = htmlDoc.CreateElement("img"); newImg.SetAttributeValue("src", src); // Ghost verwirft Galerie-Bilder ohne Breite/Höhe → möglichst die echten Maße der Datei verwenden double w = 0, h = 0; var realSize = _imageSizeResolver?.Invoke(src); if (realSize.HasValue) { w = realSize.Value.Width; h = realSize.Value.Height; } if (w <= 0 || h <= 0) { string origSize = originalImg.GetAttributeValue("data-orig-size", ""); var parts = origSize.Split(','); if (parts.Length == 2 && double.TryParse(parts[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double ow) && double.TryParse(parts[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double oh)) { w = ow; h = oh; } } if (w <= 0 || h <= 0) { if (double.TryParse(originalImg.GetAttributeValue("width", ""), NumberStyles.Float, CultureInfo.InvariantCulture, out double aw) && double.TryParse(originalImg.GetAttributeValue("height", ""), NumberStyles.Float, CultureInfo.InvariantCulture, out double ah)) { w = aw; h = ah; } } // Maße unbekannt (z.B. extern eingebundenes Bild): 3:2 annehmen, statt das Bild zu verlieren if (w <= 0 || h <= 0) { w = 1200; h = 800; } newImg.SetAttributeValue("width", ((int)w).ToString(CultureInfo.InvariantCulture)); newImg.SetAttributeValue("height", ((int)h).ToString(CultureInfo.InvariantCulture)); string ratio = (w / h).ToString("G6", CultureInfo.InvariantCulture); imgWrapper.SetAttributeValue("style", $"flex: {ratio} 1 0%;"); newImg.SetAttributeValue("loading", "lazy"); newImg.SetAttributeValue("alt", originalImg.GetAttributeValue("alt", "")); imgWrapper.AppendChild(newImg); return imgWrapper; } private static void AppendGalleryRows(HtmlDocument htmlDoc, HtmlNode container, List wrappers, int columns) { for (int i = 0; i < wrappers.Count; i += columns) { var row = htmlDoc.CreateElement("div"); row.AddClass("kg-gallery-row"); int end = Math.Min(i + columns, wrappers.Count); for (int j = i; j < end; j++) row.AppendChild(wrappers[j]); container.AppendChild(row); } } // ========================================================================= // Helper: Beste Bild-URL aus img-Element holen // Priorität: data-orig-file → data-large-file → srcset (letzter/Widest) → src // ========================================================================= private static string GetBestImageUrl(HtmlNode img) { // 1. data-orig-file (Vollbild) string src = img.GetAttributeValue("data-orig-file", ""); if (!string.IsNullOrEmpty(src)) return src; // 2. data-large-file src = img.GetAttributeValue("data-large-file", ""); if (!string.IsNullOrEmpty(src)) return src; // 3. srcset (größte Variante nehmen) string srcset = img.GetAttributeValue("srcset", ""); if (!string.IsNullOrEmpty(srcset)) { src = GetLargestFromSrcset(srcset); if (!string.IsNullOrEmpty(src)) return src; } // 4. src (Fallback) return img.GetAttributeValue("src", ""); } private static string GetLargestFromSrcset(string srcset) { string bestUrl = ""; int bestWidth = 0; foreach (string part in srcset.Split(',', StringSplitOptions.RemoveEmptyEntries)) { string trimmed = part.Trim(); string[] tokens = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (tokens.Length < 2) continue; string url = tokens[0]; string descriptor = tokens[1]; if (int.TryParse(descriptor.Replace("w", ""), out int width) && width > bestWidth) { bestWidth = width; bestUrl = url; } } return bestUrl; } // ========================================================================= // Einzelbilder (nicht in Galerien) // ========================================================================= private static void ConvertSingleImages(HtmlDocument htmlDoc) { var singleImgNodes = htmlDoc.DocumentNode.SelectNodes("//img"); if (singleImgNodes == null) return; foreach (var img in singleImgNodes.ToList()) { if (img.Ancestors("figure").Any(a => HasClass(a, "kg-gallery-card"))) continue; string src = GetBestImageUrl(img); if (string.IsNullOrEmpty(src)) continue; // Beste Variante als src setzen; srcset/sizes verweisen auf WordPress-Größenvarianten img.SetAttributeValue("src", src); img.Attributes.Remove("srcset"); img.Attributes.Remove("sizes"); var parentFigure = img.Ancestors("figure").FirstOrDefault(); if (parentFigure != null) { bool hasCaption = GetCaptionHtml(parentFigure.SelectSingleNode("./figcaption")) != null; SetClasses(parentFigure, hasCaption ? "kg-card kg-image-card kg-card-hascaption" : "kg-card kg-image-card"); img.AddClass("kg-image"); } else { var wrapper = htmlDoc.CreateElement("figure"); SetClasses(wrapper, "kg-card kg-image-card"); img.AddClass("kg-image"); var imgParent = img.ParentNode; if (imgParent != null) { imgParent.InsertBefore(wrapper, img); imgParent.RemoveChild(img); wrapper.AppendChild(img); } } } } // ========================================================================= // Figure ohne kg-card → kg-width-wide // ========================================================================= private static void ConvertFigures(HtmlDocument htmlDoc) { var figures = htmlDoc.DocumentNode.SelectNodes("//figure"); if (figures == null) return; foreach (var figure in figures.ToList()) { if (HasClass(figure, "kg-card")) continue; SetClasses(figure, "kg-width-wide kg-card-width-wide"); } } // ========================================================================= // WP-Klassen mappen // ========================================================================= private static void ConvertWpClasses(HtmlDocument htmlDoc) { var allNodes = htmlDoc.DocumentNode.SelectNodes("//*[contains(@class, 'wp-block')]"); if (allNodes == null) return; foreach (var node in allNodes.ToList()) { string classes = node.GetAttributeValue("class", ""); if (string.IsNullOrEmpty(classes)) continue; var classList = classes.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); var newClasses = new List(); bool changed = false; foreach (string cls in classList) { if (WpClassMap.TryGetValue(cls, out string? ghostClass)) { changed = true; if (ghostClass != null && ghostClass.Length > 0) newClasses.AddRange(ghostClass.Split(' ')); } else { newClasses.Add(cls); } } if (changed) { string finalClasses = string.Join(" ", newClasses.Where(c => c.Length > 0).Distinct()); if (finalClasses.Length > 0) node.SetAttributeValue("class", finalClasses); else node.Attributes.Remove("class"); } } // Separator var separators = htmlDoc.DocumentNode.SelectNodes("//div[contains(@class, 'wp-block-separator')]"); if (separators != null) { foreach (var sep in separators.ToList()) { var hr = htmlDoc.CreateElement("hr"); foreach (var attr in sep.Attributes) hr.SetAttributeValue(attr.Name, attr.Value); sep.ParentNode?.ReplaceChild(hr, sep); } } } // ========================================================================= // Externe Links mit target="_blank" versehen // ========================================================================= private static void AddTargetBlankToExternalLinks(HtmlDocument htmlDoc) { var anchors = htmlDoc.DocumentNode.SelectNodes("//a[@href]"); if (anchors == null) return; foreach (var anchor in anchors.ToList()) { string href = anchor.GetAttributeValue("href", "").Trim(); if (string.IsNullOrEmpty(href)) continue; // Nur absolute externe URLs (http/https) if (href.StartsWith("http://") || href.StartsWith("https://")) { anchor.SetAttributeValue("target", "_blank"); anchor.SetAttributeValue("rel", "noopener noreferrer"); } } } private static bool HasClass(HtmlNode node, string className) { string classes = node.GetAttributeValue("class", ""); return classes.Split(' ', StringSplitOptions.RemoveEmptyEntries) .Any(c => c.Equals(className, StringComparison.OrdinalIgnoreCase)); } private static void SetClasses(HtmlNode node, string classes) { node.SetAttributeValue("class", classes); } } }