Wp2ghost/CSharpGhostImporter/HtmlConverter.cs
2026-10-14 19:12:29 +02:00

707 lines
30 KiB
C#

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" },
};
// 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<string, (int Width, int Height)?>? _imageSizeResolver;
private static int _ghostGalleryCount;
private static int _skippedGalleryImages;
private static int _captionedGalleryImages;
public static string Convert(string html, Func<string, (int Width, int Height)?>? 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: <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 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 <img> 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
// =========================================================================
/// <summary>
/// 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).
/// </summary>
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<HtmlNode>();
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<List<HtmlNode>> SplitGallery(List<HtmlNode> wrappers)
{
var chunks = new List<List<HtmlNode>>();
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<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");
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<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);
}
}
}
// =========================================================================
// 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);
}
}
}