Skip to content

Commit ef0efa2

Browse files
committed
Improve markdown local file link resolution
1 parent 9d1c1ef commit ef0efa2

File tree

6 files changed

+262
-5
lines changed

6 files changed

+262
-5
lines changed

site/docs/controls/markdowncontrol.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ var control = new MarkdownControl(markdown)
6161
};
6262
```
6363

64+
For Markdown documents that contain local file links, use `LocalFileRootPath` to resolve relative paths into valid local `file://` URIs:
65+
66+
```csharp
67+
var control = new MarkdownControl(markdown)
68+
{
69+
Options = MarkdownRenderOptions.Default with
70+
{
71+
LocalFileRootPath = Environment.CurrentDirectory,
72+
}
73+
};
74+
```
75+
76+
Absolute local paths such as `C:\docs\guide.md` on Windows are also normalized to `file://` links automatically.
77+
6478
Spacing defaults are intentionally compact for terminal density:
6579

6680
- heading spacing before: `0`

site/docs/specs/controls/markdowncontrol.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ Runs MUST be merged when adjacent and identical to reduce allocations.
248248
- Use `HyperlinkRun(start, length, uri)` on the rendered text span.
249249
- Apply a link style run (underline + optional accent).
250250
- Resolve relative URIs against `BaseUri` when provided.
251+
- Absolute local file paths SHOULD be normalized to `file://` URIs.
252+
- Relative local file links MAY be resolved against `MarkdownRenderOptions.LocalFileRootPath` when provided. This local file root takes precedence over `BaseUri` for non-absolute, non-fragment links.
251253

252254
Images:
253255

@@ -383,4 +385,3 @@ Add a `ControlsDemo` page:
383385
- link styling,
384386
- different table presets,
385387
- alert styles.
386-

src/XenoAtom.Terminal.UI.Extensions.Markdown/MarkdownDocumentBuilder.cs

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the BSD-Clause 2 license.
33
// See license.txt file in the project root for full license information.
44

5+
using System.IO;
56
using System.Text;
67
using Markdig.Extensions.Alerts;
78
using Markdig.Extensions.Tables;
@@ -23,6 +24,7 @@ internal sealed class MarkdownDocumentBuilder
2324
private readonly MarkdownStyle _style;
2425
private readonly MarkdownRenderOptions _options;
2526
private readonly Uri? _baseUri;
27+
private readonly string? _localFileRootPath;
2628
private readonly List<DocumentFlowBlock> _blocks;
2729
private readonly int _headingSpacingBefore;
2830
private readonly int _headingSpacingAfter;
@@ -36,6 +38,7 @@ public MarkdownDocumentBuilder(MarkdownStyle style, MarkdownRenderOptions option
3638
_style = style;
3739
_options = options;
3840
_baseUri = baseUri;
41+
_localFileRootPath = NormalizeLocalFileRootPath(options.LocalFileRootPath);
3942
_blocks = new List<DocumentFlowBlock>(64);
4043
_headingSpacingBefore = Math.Max(0, _options.HeadingSpacingBefore);
4144
_headingSpacingAfter = Math.Max(0, _options.HeadingSpacingAfter);
@@ -711,17 +714,179 @@ private static string ExtractInlinePlainText(ContainerInline inline)
711714
return null;
712715
}
713716

714-
if (Uri.TryCreate(link, UriKind.Absolute, out var absolute))
717+
var trimmed = link.Trim();
718+
719+
if (TryResolveLocalFileUri(trimmed, out var localFileUri))
720+
{
721+
return localFileUri;
722+
}
723+
724+
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var absolute))
715725
{
716726
return absolute.ToString();
717727
}
718728

719-
if (_baseUri is not null && Uri.TryCreate(_baseUri, link, out var relative))
729+
if (_baseUri is not null && Uri.TryCreate(_baseUri, trimmed, out var relative))
720730
{
721731
return relative.ToString();
722732
}
723733

724-
return link;
734+
return trimmed;
735+
}
736+
737+
private bool TryResolveLocalFileUri(string link, out string? uri)
738+
{
739+
uri = null;
740+
if (IsFragmentOrQueryOnly(link))
741+
{
742+
return false;
743+
}
744+
745+
if (TryResolveAbsoluteLocalFileUri(link, out uri))
746+
{
747+
return true;
748+
}
749+
750+
if (_localFileRootPath is null)
751+
{
752+
return false;
753+
}
754+
755+
SplitPathAndSuffix(link, out var pathPart, out var suffix);
756+
if (string.IsNullOrWhiteSpace(pathPart))
757+
{
758+
return false;
759+
}
760+
761+
var normalizedRelativePath = NormalizeRelativeFilePath(pathPart);
762+
var combinedPath = Path.GetFullPath(Path.Combine(_localFileRootPath, normalizedRelativePath));
763+
uri = CreateFileUri(combinedPath, suffix);
764+
return true;
765+
}
766+
767+
private static string? NormalizeLocalFileRootPath(string? rootPath)
768+
{
769+
if (string.IsNullOrWhiteSpace(rootPath))
770+
{
771+
return null;
772+
}
773+
774+
return Path.GetFullPath(rootPath);
775+
}
776+
777+
private static bool TryResolveAbsoluteLocalFileUri(string link, out string? uri)
778+
{
779+
SplitPathAndSuffix(link, out var pathPart, out var suffix);
780+
781+
if (IsWindowsDrivePath(pathPart))
782+
{
783+
uri = CreateFileUri(pathPart.Replace('/', '\\'), suffix);
784+
return true;
785+
}
786+
787+
if (IsWindowsUncPath(pathPart))
788+
{
789+
uri = CreateFileUri(pathPart, suffix);
790+
return true;
791+
}
792+
793+
if (IsUnixAbsolutePath(pathPart))
794+
{
795+
uri = CreateFileUri(pathPart, suffix);
796+
return true;
797+
}
798+
799+
uri = null;
800+
return false;
801+
}
802+
803+
private static bool IsWindowsDrivePath(string path)
804+
{
805+
return path.Length >= 3
806+
&& IsAsciiLetter(path[0])
807+
&& path[1] == ':'
808+
&& IsDirectorySeparator(path[2]);
809+
}
810+
811+
private static bool IsWindowsUncPath(string path)
812+
{
813+
return path.Length >= 2 && path[0] == '\\' && path[1] == '\\';
814+
}
815+
816+
private static bool IsUnixAbsolutePath(string path)
817+
{
818+
return !OperatingSystem.IsWindows() && path.Length > 0 && path[0] == '/';
819+
}
820+
821+
private static bool IsAsciiLetter(char c)
822+
{
823+
c = char.ToUpperInvariant(c);
824+
return c >= 'A' && c <= 'Z';
825+
}
826+
827+
private static bool IsDirectorySeparator(char c) => c is '\\' or '/';
828+
829+
private static bool IsFragmentOrQueryOnly(string link) => link.Length > 0 && link[0] is '#' or '?';
830+
831+
private static string NormalizeRelativeFilePath(string path)
832+
{
833+
var normalized = path;
834+
if (Path.DirectorySeparatorChar == '\\')
835+
{
836+
normalized = normalized.Replace('/', '\\');
837+
}
838+
else
839+
{
840+
normalized = normalized.Replace('\\', '/');
841+
}
842+
843+
return normalized.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
844+
}
845+
846+
private static void SplitPathAndSuffix(string link, out string pathPart, out string suffix)
847+
{
848+
var queryIndex = link.IndexOf('?');
849+
var fragmentIndex = link.IndexOf('#');
850+
851+
var suffixIndex = -1;
852+
if (queryIndex >= 0 && fragmentIndex >= 0)
853+
{
854+
suffixIndex = Math.Min(queryIndex, fragmentIndex);
855+
}
856+
else if (queryIndex >= 0)
857+
{
858+
suffixIndex = queryIndex;
859+
}
860+
else if (fragmentIndex >= 0)
861+
{
862+
suffixIndex = fragmentIndex;
863+
}
864+
865+
if (suffixIndex < 0)
866+
{
867+
pathPart = link;
868+
suffix = string.Empty;
869+
return;
870+
}
871+
872+
pathPart = link[..suffixIndex];
873+
suffix = link[suffixIndex..];
874+
}
875+
876+
private static string CreateFileUri(string path, string suffix)
877+
{
878+
var fileUri = new UriBuilder(Uri.UriSchemeFile, string.Empty, -1, path).Uri;
879+
if (string.IsNullOrEmpty(suffix))
880+
{
881+
return fileUri.AbsoluteUri;
882+
}
883+
884+
if (Uri.TryCreate(fileUri, suffix, out var resolved))
885+
{
886+
return resolved.AbsoluteUri;
887+
}
888+
889+
return string.Concat(fileUri.AbsoluteUri, suffix);
725890
}
726891

727892
private readonly record struct InlineRenderResult(string Text, StyledRun[] Runs, HyperlinkRun[] Hyperlinks);

src/XenoAtom.Terminal.UI.Extensions.Markdown/MarkdownRenderOptions.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the BSD-Clause 2 license.
33
// See license.txt file in the project root for full license information.
44

5+
using XenoAtom.Terminal.UI.Controls;
56
using XenoAtom.Terminal.UI.Styling;
67

78
namespace XenoAtom.Terminal.UI.Extensions.Markdown;
@@ -46,6 +47,16 @@ public sealed record MarkdownRenderOptions
4647
/// </summary>
4748
public bool RenderImagesAsLinks { get; init; } = true;
4849

50+
/// <summary>
51+
/// Gets an optional local file-system root path used to resolve relative Markdown links into <c>file://</c> URIs.
52+
/// </summary>
53+
/// <remarks>
54+
/// When specified, non-absolute links that are not pure fragment/query references are combined with this root path
55+
/// before <see cref="MarkdownControl.BaseUri"/> is considered. Use this when Markdown documents contain local relative
56+
/// file references instead of web-relative links.
57+
/// </remarks>
58+
public string? LocalFileRootPath { get; init; }
59+
4960
/// <summary>
5061
/// Gets the default table style for Markdown tables.
5162
/// </summary>

src/XenoAtom.Terminal.UI.Extensions.Markdown/readme.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ Paragraph with **bold** text and [a link](https://example.com).
2424
var control = new MarkdownControl(markdown);
2525
```
2626

27+
Resolve relative file links locally while still supporting standard web `BaseUri` resolution:
28+
29+
```csharp
30+
var control = new MarkdownControl(markdown)
31+
{
32+
Options = MarkdownRenderOptions.Default with
33+
{
34+
LocalFileRootPath = Environment.CurrentDirectory,
35+
},
36+
};
37+
```
38+
2739
Convert interpreted markdown into markup:
2840

2941
```csharp
@@ -44,7 +56,7 @@ var runs = converter.Highlight(markdown); // StyledRun[] over the original markd
4456

4557
- CommonMark block and inline rendering.
4658
- Extensions enabled by default: pipe tables and alert blocks.
47-
- `MarkdownRenderOptions` for code block wrapping/height, compact spacing, and HTML/image fallbacks.
59+
- `MarkdownRenderOptions` for code block wrapping/height, compact spacing, HTML/image fallbacks, and local file-link resolution.
4860
- Theme-aware pleasant defaults (bright-yellow headings, accent strong text, bright-red inline code, semantic alerts).
4961
- `MarkdownStyle` for heading/link/emphasis/alert style customization.
5062
- `MarkdownDocumentContent` for direct usage with `DocumentFlow` feeds.

src/XenoAtom.Terminal.UI.Tests/MarkdownControlTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the BSD-Clause 2 license.
33
// See license.txt file in the project root for full license information.
44

5+
using System.IO;
56
using XenoAtom.Terminal.UI.Controls;
67
using XenoAtom.Terminal.UI.Extensions.Markdown;
78
using XenoAtom.Terminal.UI.Extensions.Markdown.Styling;
@@ -320,6 +321,54 @@ public void MarkdownControl_Resolves_Relative_Links_With_BaseUri()
320321
Assert.AreEqual("https://example.com/docs/guide/readme.md", paragraph.Hyperlinks[0].Uri);
321322
}
322323

324+
[TestMethod]
325+
public void MarkdownControl_Converts_Absolute_Windows_File_Links_To_FileUris()
326+
{
327+
const string path = @"C:\docs\guide.md";
328+
var control = new MarkdownControl($"See [docs]({path}).");
329+
330+
var paragraph = GetParagraph(control, 0);
331+
Assert.AreEqual(1, paragraph.Hyperlinks.Length);
332+
Assert.AreEqual(CreateExpectedFileUri(path), paragraph.Hyperlinks[0].Uri);
333+
}
334+
335+
[TestMethod]
336+
public void MarkdownControl_Resolves_Relative_File_Links_With_LocalFileRootPath()
337+
{
338+
var localRoot = Path.Combine(Path.GetTempPath(), "markdown-local-root");
339+
var control = new MarkdownControl("See [docs](guide/readme.md).")
340+
{
341+
BaseUri = new Uri("https://example.com/docs/"),
342+
Options = MarkdownRenderOptions.Default with
343+
{
344+
LocalFileRootPath = localRoot,
345+
},
346+
};
347+
348+
var paragraph = GetParagraph(control, 0);
349+
Assert.AreEqual(1, paragraph.Hyperlinks.Length);
350+
Assert.AreEqual(
351+
CreateExpectedFileUri(Path.GetFullPath(Path.Combine(localRoot, "guide", "readme.md"))),
352+
paragraph.Hyperlinks[0].Uri);
353+
}
354+
355+
[TestMethod]
356+
public void MarkdownControl_Keeps_Fragment_Links_Resolvable_With_BaseUri_When_LocalFileRootPath_Is_Set()
357+
{
358+
var control = new MarkdownControl("See [section](#intro).")
359+
{
360+
BaseUri = new Uri("https://example.com/docs/page.md"),
361+
Options = MarkdownRenderOptions.Default with
362+
{
363+
LocalFileRootPath = Path.Combine(Path.GetTempPath(), "markdown-local-root"),
364+
},
365+
};
366+
367+
var paragraph = GetParagraph(control, 0);
368+
Assert.AreEqual(1, paragraph.Hyperlinks.Length);
369+
Assert.AreEqual("https://example.com/docs/page.md#intro", paragraph.Hyperlinks[0].Uri);
370+
}
371+
323372
[TestMethod]
324373
public void MarkdownControl_Renders_Images_As_Link_Placeholders()
325374
{
@@ -851,4 +900,9 @@ private static bool IsLightTheme(Theme theme)
851900
var foregroundLuminance = foreground.GetRelativeLuminance();
852901
return backgroundLuminance > foregroundLuminance && backgroundLuminance >= 0.55f;
853902
}
903+
904+
private static string CreateExpectedFileUri(string path)
905+
{
906+
return new UriBuilder(Uri.UriSchemeFile, string.Empty, -1, path).Uri.AbsoluteUri;
907+
}
854908
}

0 commit comments

Comments
 (0)