diff --git a/PowerForge.Tests/WebReleaseHubRenderingTests.cs b/PowerForge.Tests/WebReleaseHubRenderingTests.cs index 863164e3..6c5130a5 100644 --- a/PowerForge.Tests/WebReleaseHubRenderingTests.cs +++ b/PowerForge.Tests/WebReleaseHubRenderingTests.cs @@ -70,6 +70,24 @@ public void Build_RendersReleaseShortcodes_FromPlacementConfig() Assert.DoesNotContain("v1.3.0-preview1", html, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Build_ReleaseChangelog_NamespacesRepeatedHeadingIdsPerRelease() + { + var html = BuildSinglePageSite( + """ + {{< release-changelog product="intelligencex.chat" limit="5" includePreview="true" >}} + """, + setup: WriteDuplicateHeadingReleaseHubData, + useScribanTheme: false, + scribanLayoutBody: null); + + Assert.Contains("id=\"v1-2-0-whats-changed\"", html, StringComparison.OrdinalIgnoreCase); + Assert.Contains("id=\"v1-3-0-whats-changed\"", html, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("id=\"whats-changed\"", html, StringComparison.OrdinalIgnoreCase); + Assert.Contains("href=\"#v1-2-0-whats-changed\"", html, StringComparison.OrdinalIgnoreCase); + Assert.Contains("href=\"#v1-3-0-whats-changed\"", html, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void Build_RendersReleaseButtons_ForAllProducts_WithWildcardFilter() { @@ -401,4 +419,62 @@ private static void WriteProjectScopedReleaseHubData(string root, string slug) } """); } + + private static void WriteDuplicateHeadingReleaseHubData(string root) + { + var dataDir = Path.Combine(root, "data"); + Directory.CreateDirectory(dataDir); + File.WriteAllText(Path.Combine(dataDir, "release-hub.json"), + """ + { + "title": "IntelligenceX Releases", + "products": [ + { "id": "intelligencex.chat", "name": "IX Chat", "order": 10 } + ], + "releases": [ + { + "tag": "v1.2.0", + "title": "IntelligenceX 1.2.0", + "url": "https://github.com/EvotecIT/IntelligenceX/releases/tag/v1.2.0", + "publishedAt": "2026-02-25T10:00:00Z", + "isPrerelease": false, + "isLatestStable": true, + "body_md": "## What's Changed\n- Stable improvements\n\n[Jump](#whats-changed)", + "assets": [ + { + "name": "IntelligenceX.Chat-win-x64-v1.2.0.zip", + "downloadUrl": "https://example.test/downloads/ix-chat-win-x64-v1.2.0.zip", + "product": "intelligencex.chat", + "channel": "stable", + "platform": "windows", + "arch": "x64", + "kind": "zip", + "size": 5242880 + } + ] + }, + { + "tag": "v1.3.0", + "title": "IntelligenceX 1.3.0", + "url": "https://github.com/EvotecIT/IntelligenceX/releases/tag/v1.3.0", + "publishedAt": "2026-02-27T10:00:00Z", + "isPrerelease": false, + "body_md": "## What's Changed\n- More improvements\n\n[Jump](#whats-changed)", + "assets": [ + { + "name": "IntelligenceX.Chat-win-x64-v1.3.0.zip", + "downloadUrl": "https://example.test/downloads/ix-chat-win-x64-v1.3.0.zip", + "product": "intelligencex.chat", + "channel": "stable", + "platform": "windows", + "arch": "x64", + "kind": "zip", + "size": 5400000 + } + ] + } + ] + } + """); + } } diff --git a/PowerForge.Web/Services/ReleaseHubRenderer.cs b/PowerForge.Web/Services/ReleaseHubRenderer.cs index ea8643b6..b5a931db 100644 --- a/PowerForge.Web/Services/ReleaseHubRenderer.cs +++ b/PowerForge.Web/Services/ReleaseHubRenderer.cs @@ -1,11 +1,21 @@ using System.Globalization; using System.Text; +using System.Text.RegularExpressions; namespace PowerForge.Web; internal static class ReleaseHubRenderer { private const string DefaultDataPath = "release_hub"; + private static readonly Regex HeadingWithIdRegex = new( + "[1-6])(?[^>]*)>(?.*?)", + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline); + private static readonly Regex IdAttributeRegex = new( + "\\sid\\s*=\\s*([\"'])(?[^\"']+)\\1", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex FragmentLinkRegex = new( + "(?]*\\bhref\\s*=\\s*)(?[\"'])#(?[^\"'#]+)(\\k)(?[^>]*>)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); internal static string RenderReleaseButton( IReadOnlyDictionary data, @@ -384,7 +394,7 @@ internal static string RenderReleaseChangelog( if (!string.IsNullOrWhiteSpace(bodyMarkdown)) bodyHtml = MarkdownRenderer.RenderToHtml(bodyMarkdown, markdown); } - release.BodyHtml = bodyHtml ?? string.Empty; + release.BodyHtml = NamespaceReleaseBodyHtml(bodyHtml ?? string.Empty, release.Tag); if (TryReadList(releaseMap, "assets", out var assetValues)) { @@ -518,6 +528,71 @@ private static List FindAssets( return matches; } + private static string NamespaceReleaseBodyHtml(string html, string? releaseTag) + { + if (string.IsNullOrWhiteSpace(html)) + return string.Empty; + + var prefix = Slugify(releaseTag); + if (string.IsNullOrWhiteSpace(prefix)) + return html; + + var namespaced = HeadingWithIdRegex.Replace(html, match => + { + var level = match.Groups["level"].Value; + var attrs = match.Groups["attrs"].Value; + var text = match.Groups["text"].Value; + + var idMatch = IdAttributeRegex.Match(attrs); + var baseId = idMatch.Success + ? idMatch.Groups["id"].Value + : Slugify(Regex.Replace(text, "<.*?>", string.Empty)); + if (string.IsNullOrWhiteSpace(baseId)) + return match.Value; + + var namespacedId = NamespaceFragment(prefix, baseId); + var attrsWithId = idMatch.Success + ? IdAttributeRegex.Replace(attrs, $" id=\"{namespacedId}\"", 1) + : (string.IsNullOrWhiteSpace(attrs) ? $" id=\"{namespacedId}\"" : $"{attrs} id=\"{namespacedId}\""); + return $"{text}"; + }); + + return FragmentLinkRegex.Replace(namespaced, match => + { + var target = match.Groups["target"].Value; + if (string.IsNullOrWhiteSpace(target)) + return match.Value; + + var namespacedTarget = NamespaceFragment(prefix, target); + return $"{match.Groups["prefix"].Value}{match.Groups["quote"].Value}#{namespacedTarget}{match.Groups["quote"].Value}{match.Groups["suffix"].Value}"; + }); + } + + private static string NamespaceFragment(string prefix, string fragment) + { + var normalizedPrefix = Slugify(prefix); + var normalizedFragment = Slugify(fragment); + if (string.IsNullOrWhiteSpace(normalizedPrefix)) + return normalizedFragment; + if (string.IsNullOrWhiteSpace(normalizedFragment)) + return normalizedPrefix; + if (normalizedFragment.StartsWith(normalizedPrefix + "-", StringComparison.Ordinal)) + return normalizedFragment; + return $"{normalizedPrefix}-{normalizedFragment}"; + } + + private static string Slugify(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var slug = value.Trim().ToLowerInvariant(); + slug = Regex.Replace(slug, "[^a-z0-9\\s-]", "-"); + slug = Regex.Replace(slug, "\\s+", "-"); + slug = Regex.Replace(slug, "-{2,}", "-"); + return slug.Trim('-'); + } + private static ReleaseButtonsGrouping NormalizeGrouping(string? value) { var normalized = NormalizeFilter(value);