Wp2ghost/CSharpGhostImporter/HtmlConverter.cs

639 lines
27 KiB
C#
Raw Normal View History

2026-09-20 08:31:23 +02:00
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<string, string?> 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" },
};
public static string Convert(string html)
{
if (string.IsNullOrWhiteSpace(html)) return string.Empty;
var htmlDoc = new HtmlDocument();
htmlDoc.LoadHtml(html);
int totalGalleries = 0;
totalGalleries += ConvertClassicGalleries(htmlDoc);
totalGalleries += ConvertGutenbergGalleries(htmlDoc);
totalGalleries += ConvertJetpackGalleries(htmlDoc);
totalGalleries += ConvertCoBlocksGalleries(htmlDoc);
if (totalGalleries > 0)
Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert");
ConvertSingleImages(htmlDoc);
ConvertFigures(htmlDoc);
ConvertWpClasses(htmlDoc);
2026-09-22 08:08:51 +02:00
AddTargetBlankToExternalLinks(htmlDoc);
2026-09-20 08:31:23 +02:00
return htmlDoc.DocumentNode.OuterHtml;
}
// =========================================================================
// Gallery-Typ 1: WordPress Classic Gallery
// HTML: <div class='gallery gallery-columns-3'>
// <figure class='gallery-item'>
// <div class='gallery-icon portrait'>
// <a href='...'><img src='thumb' data-orig-file='full.jpg' /></a>
// </div>
// </figure>
// </div>
// =========================================================================
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 ghostGallery = htmlDoc.CreateElement("figure");
SetClasses(ghostGallery, "kg-card kg-gallery-card");
AddWideClass(ghostGallery, gallery);
var container = htmlDoc.CreateElement("div");
container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>();
2026-09-22 08:08:51 +02:00
var imageCaptions = new List<string>();
2026-09-20 08:31:23 +02:00
foreach (var item in items)
{
var img = item.SelectSingleNode(".//img");
if (img == null) continue;
2026-09-22 08:08:51 +02:00
// Bild-Titel aus data-image-caption Attribut extrahieren
string dataCaption = img.GetAttributeValue("data-image-caption", "").Trim();
if (!string.IsNullOrEmpty(dataCaption))
imageCaptions.Add(dataCaption);
// Auch figcaption innerhalb des gallery-items prüfen
var figCap = item.SelectSingleNode(".//figcaption");
if (figCap != null && !string.IsNullOrWhiteSpace(figCap.InnerText))
{
string capText = figCap.InnerText.Trim();
if (!string.IsNullOrEmpty(capText) && !imageCaptions.Contains(capText))
imageCaptions.Add(capText);
}
2026-09-20 08:31:23 +02:00
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue;
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
}
if (wrappers.Count == 0) continue;
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container);
2026-09-22 08:08:51 +02:00
// Gallery-Bild-Titel übertragen, wenn vorhanden
if (imageCaptions.Count > 0)
2026-09-20 08:31:23 +02:00
{
2026-09-22 08:08:51 +02:00
// Ersten nicht-leeren Titel nehmen als Gallery-Kapitel
string firstCaption = imageCaptions[0];
2026-09-20 08:31:23 +02:00
var newCaption = htmlDoc.CreateElement("figcaption");
2026-09-22 08:08:51 +02:00
newCaption.InnerHtml = firstCaption;
2026-09-20 08:31:23 +02:00
ghostGallery.AppendChild(newCaption);
ghostGallery.AddClass("kg-card-hascaption");
}
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
count++;
}
return count;
}
// =========================================================================
// Gallery-Typ 2: WordPress Gutenberg Gallery
// =========================================================================
private static int ConvertGutenbergGalleries(HtmlDocument htmlDoc)
{
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
"//figure[contains(@class, 'wp-block-gallery')] | " +
"//div[contains(@class, 'wp-block-gallery')] | " +
"//ul[contains(@class, 'wp-block-gallery')]"
);
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 ghostGallery = htmlDoc.CreateElement("figure");
SetClasses(ghostGallery, "kg-card kg-gallery-card");
AddWideClass(ghostGallery, gallery);
// Neue Format-Prüfung: data-carousel-extra Attribut speichern
string carouselExtra = gallery.GetAttributeValue("data-carousel-extra", "");
2026-09-20 08:31:23 +02:00
var container = htmlDoc.CreateElement("div");
container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>();
foreach (var img in imgs)
{
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue;
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
}
if (wrappers.Count == 0) continue;
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container);
2026-09-22 08:08:51 +02:00
// Alle individuellen Bildunterschriften aus der Gutenberg-Gallery sammeln
var captionNodes = gallery.SelectNodes(".//figcaption");
if (captionNodes != null && captionNodes.Count > 0)
2026-09-20 08:31:23 +02:00
{
2026-09-22 08:08:51 +02:00
var distinctCaptions = captionNodes
.Select(n => n.InnerText.Trim())
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct()
.ToList();
if (distinctCaptions.Count > 0)
{
var newCaption = htmlDoc.CreateElement("figcaption");
newCaption.InnerHtml = string.Join(" · ", distinctCaptions);
ghostGallery.AppendChild(newCaption);
ghostGallery.AddClass("kg-card-hascaption");
}
2026-09-20 08:31:23 +02:00
}
// data-carousel-extra als Ghost-Attribut übertragen (falls vorhanden)
if (!string.IsNullOrEmpty(carouselExtra))
{
var extraAttr = htmlDoc.CreateAttribute("data-carousel-extra");
extraAttr.Value = carouselExtra;
ghostGallery.Attributes.Append(extraAttr);
}
2026-09-20 08:31:23 +02:00
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
count++;
}
return count;
}
// =========================================================================
// Gallery-Typ 3: Jetpack Tiled Gallery
// =========================================================================
private static int ConvertJetpackGalleries(HtmlDocument htmlDoc)
{
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
"//div[contains(@class, 'wp-block-jetpack-tiled-gallery')] | " +
"//figure[contains(@class, 'wp-block-jetpack-tiled-gallery')]"
);
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 ghostGallery = htmlDoc.CreateElement("figure");
SetClasses(ghostGallery, "kg-card kg-gallery-card");
AddWideClass(ghostGallery, gallery);
var container = htmlDoc.CreateElement("div");
container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>();
foreach (var img in imgs)
{
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue;
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
}
if (wrappers.Count == 0) continue;
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container);
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
count++;
}
return count;
}
// =========================================================================
// Gallery-Typ 4: CoBlocks Gallery
// =========================================================================
private static int ConvertCoBlocksGalleries(HtmlDocument htmlDoc)
{
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
"//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 (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 ghostGallery = htmlDoc.CreateElement("figure");
SetClasses(ghostGallery, "kg-card kg-gallery-card");
AddWideClass(ghostGallery, gallery);
var container = htmlDoc.CreateElement("div");
container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>();
foreach (var img in imgs)
{
string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue;
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
}
if (wrappers.Count == 0) continue;
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container);
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
count++;
}
return count;
}
// =========================================================================
// Galerie-Helfer: Spaltenanzahl, Wide-Klasse, Bild-Wrapper, Zeilen
// =========================================================================
private static int GetGalleryColumns(HtmlNode gallery)
{
string classes = gallery.GetAttributeValue("class", "");
var match = Regex.Match(classes, @"columns-(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out int cols) && cols >= 1 && cols <= 9)
return cols;
return 3;
}
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 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);
double w = 0, h = 0;
string origSize = originalImg.GetAttributeValue("data-orig-size", "");
if (!string.IsNullOrEmpty(origSize))
{
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;
}
}
if (w > 0 && h > 0)
{
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%;");
}
else
{
imgWrapper.SetAttributeValue("style", "flex: 1 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<HtmlNode> 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");
2026-09-20 08:31:23 +02:00
var parentFigure = img.Ancestors("figure").FirstOrDefault();
if (parentFigure != null)
{
SetClasses(parentFigure, "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<string>();
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);
}
}
}
2026-09-22 08:08:51 +02:00
// =========================================================================
// 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");
}
}
}
2026-09-20 08:31:23 +02:00
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);
}
}
}