Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions PowerForge.Tests/WebReleaseHubRenderingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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
}
]
}
]
}
""");
}
}
77 changes: 76 additions & 1 deletion PowerForge.Web/Services/ReleaseHubRenderer.cs
Original file line number Diff line number Diff line change
@@ -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(
"<h(?<level>[1-6])(?<attrs>[^>]*)>(?<text>.*?)</h\\1>",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline);
private static readonly Regex IdAttributeRegex = new(
"\\sid\\s*=\\s*([\"'])(?<id>[^\"']+)\\1",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly Regex FragmentLinkRegex = new(
"(?<prefix><a\\b[^>]*\\bhref\\s*=\\s*)(?<quote>[\"'])#(?<target>[^\"'#]+)(\\k<quote>)(?<suffix>[^>]*>)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);

internal static string RenderReleaseButton(
IReadOnlyDictionary<string, object?> data,
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -518,6 +528,71 @@ private static List<ReleaseHubAssetMatch> 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 $"<h{level}{attrsWithId}>{text}</h{level}>";
});

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}";
Comment on lines +566 to +567

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rewriting non-heading fragment links

The link rewrite currently namespaces every href="#..." in release bodies, but only heading IDs are renamed. This breaks valid in-body links that target non-heading anchors (for example custom <a id="...">, footnotes, or intentionally cross-release fragments), because those target IDs are never updated. In those cases navigation silently regresses after this commit. Consider restricting rewrites to fragment IDs that were actually remapped from headings.

Useful? React with 👍 / 👎.

});
}

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);
Expand Down
Loading