Skip to content

Commit 0317374

Browse files
NetdocsCopilot
andcommitted
fix(links): preserve custom URI schemes and dots in slugified URLs
Two link-correctness issues surfaced by a docs-parity audit: 1. Custom-scheme links (e.g. `vscode:extension/ms-python.python`, `tel:`) were mangled into relative resource links because the rewriter only treated `://` URLs as external. Replace the ad-hoc scheme checks with a single URI-scheme detector so any `scheme:` link is left untouched. 2. slugify.urls dropped dots entirely, so `2025.05.05` date/version segments became `20250505`. Periods are URL-safe and appear in dates and version numbers, so keep them: Slug.Make gains an optional `keep` set and UrlFor preserves `.` when slugifying content URL segments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2f24e2e commit 0317374

4 files changed

Lines changed: 51 additions & 9 deletions

File tree

src/Netdocs.Abstractions/Slug.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ public static class Slug
1010
public static string Make(string text, string separator = "-") =>
1111
Make(text, new SlugifyConfig { Separator = separator });
1212

13-
/// <summary>Slugify honoring a <see cref="SlugifyConfig"/> (case, separator, ASCII folding).</summary>
14-
public static string Make(string text, SlugifyConfig config)
13+
/// <summary>Slugify honoring a <see cref="SlugifyConfig"/> (case, separator, ASCII folding).
14+
/// Characters listed in <paramref name="keep"/> are preserved verbatim instead of being
15+
/// dropped — used by URL slugification to retain URL-safe punctuation such as <c>.</c>
16+
/// (so e.g. a <c>2025.05.05</c> date segment stays intact rather than becoming
17+
/// <c>20250505</c>).</summary>
18+
public static string Make(string text, SlugifyConfig config, string keep = "")
1519
{
1620
var normalized = text.Normalize(NormalizationForm.FormD);
1721
var sb = new StringBuilder(normalized.Length);
@@ -25,6 +29,10 @@ public static string Make(string text, SlugifyConfig config)
2529
if (config.Ascii && c > 127) continue; // drop non-ASCII letters/digits when ASCII-only
2630
sb.Append(ApplyCase(c, config.Case));
2731
}
32+
else if (keep.Length > 0 && keep.IndexOf(c) >= 0)
33+
{
34+
sb.Append(c);
35+
}
2836
else if (char.IsWhiteSpace(c) || c is '-' or '_' or '/')
2937
{
3038
sb.Append(' ');

src/Netdocs.Core/Content/ContentDiscovery.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public static string UrlFor(string relative, SlugifyConfig? slugify = null)
8484

8585
var urlSegments = isIndex ? segments[..^1] : segments;
8686
if (slugify is not null)
87-
urlSegments = [.. urlSegments.Select(s => Slug.Make(s, slugify))];
87+
urlSegments = [.. urlSegments.Select(s => Slug.Make(s, slugify, "."))];
8888

8989
var dir = string.Join('/', urlSegments);
9090
return dir.Length == 0 ? "" : dir + "/";

src/Netdocs.Core/Markdown/DocumentRenderer.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text;
2+
using System.Text.RegularExpressions;
23
using AngleSharp.Html.Parser;
34
using Markdig;
45
using Markdig.Renderers;
@@ -10,13 +11,24 @@
1011
namespace Netdocs.Core.Markdown;
1112

1213
/// <summary>Renders processed markdown to HTML and extracts TOC, title, and plain text.</summary>
13-
public sealed class DocumentRenderer(
14+
public sealed partial class DocumentRenderer(
1415
MarkdownPipeline pipeline,
1516
IReadOnlyDictionary<string, string>? linkMap = null,
1617
bool abbreviationsFirstInstanceOnly = false)
1718
{
1819
private readonly HtmlParser _htmlParser = new();
1920

21+
// Matches a URI scheme prefix (e.g. `http:`, `mailto:`, `data:`, `vscode:`, `tel:`).
22+
// Custom schemes like `vscode:extension/...` have no `//`, so a bare `://` check misses
23+
// them; anything with a scheme must be left untouched, not treated as a relative path.
24+
[GeneratedRegex(@"^[a-zA-Z][a-zA-Z0-9+.\-]*:", RegexOptions.CultureInvariant)]
25+
private static partial Regex UriSchemeRegex();
26+
27+
/// <summary>True when a link is absolute (root-relative), an in-page anchor, or carries a
28+
/// URI scheme — i.e. it must not be rewritten as a page/resource-relative link.</summary>
29+
internal static bool IsAbsoluteOrExternal(string url) =>
30+
url.StartsWith('/') || url.StartsWith('#') || UriSchemeRegex().IsMatch(url);
31+
2032
public void Render(Page page)
2133
{
2234
var document = Markdig.Markdown.Parse(page.ProcessedMarkdown, pipeline);
@@ -73,9 +85,7 @@ private void RewriteMarkdownLinks(MarkdownDocument document, string currentRelat
7385
/// (no leading slash; callers prepend a base-relative prefix).</summary>
7486
internal static string? ResolveResourceLink(string currentRelativePath, string url, bool isImage)
7587
{
76-
if (url.Contains("://") || url.StartsWith('/') || url.StartsWith('#') ||
77-
url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ||
78-
url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
88+
if (IsAbsoluteOrExternal(url))
7989
return null;
8090

8191
var cut = url.IndexOfAny(['#', '?']);
@@ -99,8 +109,7 @@ private void RewriteMarkdownLinks(MarkdownDocument document, string currentRelat
99109

100110
internal static string? ResolveMarkdownLink(string currentRelativePath, string url, IReadOnlyDictionary<string, string> map)
101111
{
102-
if (url.Contains("://") || url.StartsWith('/') || url.StartsWith('#') ||
103-
url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
112+
if (IsAbsoluteOrExternal(url))
104113
return null;
105114

106115
var hash = url.IndexOf('#');

tests/Netdocs.Core.Tests/CoreTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public void UrlFor_ProducesDirectoryUrls(string relative, string expected)
2424
[InlineData("cluster-setup/Deploying MetalLB.md", "cluster-setup/deploying-metallb/")]
2525
[InlineData("Applications/Kubernetes/index.md", "applications/kubernetes/")]
2626
[InlineData("Guide_One/Step Two.md", "guide-one/step-two/")]
27+
[InlineData("changelogs/2025/Q2/2025.05.05.md", "changelogs/2025/q2/2025.05.05/")]
28+
[InlineData("releases/v1.2.3 Notes.md", "releases/v1.2.3-notes/")]
2729
public void UrlFor_WithSlugify_SlugifiesSegments(string relative, string expected)
2830
=> Assert.Equal(expected, ContentDiscovery.UrlFor(relative, new SlugifyConfig()));
2931

@@ -394,4 +396,27 @@ public void MarkdownLinks_WithPercentEncodedSpaces_ResolveToSlugifiedTarget()
394396
Assert.Contains("href=\"../../cluster/openshift-registry-setup/\"", page.HtmlContent);
395397
Assert.DoesNotContain("%20", page.HtmlContent);
396398
}
399+
400+
[Theory]
401+
[InlineData("vscode:extension/ms-python.python")]
402+
[InlineData("mailto:team@example.com")]
403+
[InlineData("tel:+15551234567")]
404+
[InlineData("https://example.com/docs.md")]
405+
public void CustomSchemeLinks_AreLeftUntouched(string url)
406+
{
407+
// Links carrying any URI scheme (not just `://` ones) must not be rewritten as
408+
// relative page/resource links. Regression guard for `vscode:extension/...` deep links.
409+
var site = new SiteContext { Config = new SiteConfig(), Options = new BuildOptions(), LoggerFactory = NullLoggerFactory.Instance };
410+
var pipeline = MarkdownPipelineFactory.Build(site, []);
411+
var page = new Page
412+
{
413+
SourcePath = "tools/guide.md",
414+
RelativePath = "tools/guide.md",
415+
Url = "tools/guide/",
416+
ProcessedMarkdown = $"Install [it]({url}).",
417+
};
418+
new DocumentRenderer(pipeline, new Dictionary<string, string>()).Render(page);
419+
420+
Assert.Contains($"href=\"{url}\"", page.HtmlContent);
421+
}
397422
}

0 commit comments

Comments
 (0)