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);