diff --git a/kampose.json b/kampose.json
index 10151996..1fd7ae3f 100644
--- a/kampose.json
+++ b/kampose.json
@@ -6,10 +6,11 @@
"recommended"
],
"includeImplicitConstructors": true,
+ "verifyExternalLinks": false,
"stopOnIssues": true
},
"assemblies": [
- "src/bin/Release/**/Kampute.DocToolkit.dll"
+ "src/bin/**/Kampute.DocToolkit.dll"
],
"topics": [
"docs/**/*.md"
diff --git a/src/DocumentationContext.cs b/src/DocumentationContext.cs
index 158e1515..2ffc15d8 100644
--- a/src/DocumentationContext.cs
+++ b/src/DocumentationContext.cs
@@ -85,7 +85,7 @@ public DocumentationContext
? new TopicCollection(this, (_, topic) => ToScopedTopic(topic), topics)
: throw new ArgumentNullException(nameof(topics));
- UrlTransformer = new ContextAwareUrlTransformer(this);
+ UrlTransformer = CreateUrlTransformer();
namespaces = new(GetAllNamespaces);
types = new(GetAllTypes);
@@ -216,6 +216,20 @@ protected void Dispose(bool disposing)
///
protected virtual TopicModel ToScopedTopic(ITopic topic) => new(this, topic ?? throw new ArgumentNullException(nameof(topic)));
+ ///
+ /// Creates the URL transformer for the documentation context.
+ ///
+ /// The URL transformer to use for transforming non-API URLs in the documentation.
+ ///
+ /// This method creates an implementation of for transforming non-API
+ /// site-root-relative URLs to absolute or document-relative URLs.
+ ///
+ /// The default implementation returns an instance of the class.
+ /// Override this method in derived classes to provide a custom URL transformer implementation if needed.
+ ///
+ ///
+ protected virtual IUrlTransformer CreateUrlTransformer() => new ContextAwareUrlTransformer(this);
+
///
/// Retrieves all unique namespaces with exported types from the assemblies in the documentation context.
///
diff --git a/src/DocumentationContextExtensions.cs b/src/DocumentationContextExtensions.cs
index 6750dffb..4a9865a6 100644
--- a/src/DocumentationContextExtensions.cs
+++ b/src/DocumentationContextExtensions.cs
@@ -180,16 +180,27 @@ public static NameQualifier DetermineNameQualifier(this IDocumentationContext co
if (member is null)
throw new ArgumentNullException(nameof(member));
+ // Types always require declaring type qualification
if (member is IType)
return NameQualifier.DeclaringType;
- if (member is IWithExtensionBehavior { IsExtension: true } or IOperator)
+ // Members in the current type scope do not require qualification
+ if (IsMemberInCurrentTypeScope())
return NameQualifier.None;
- return context.AddressProvider.ActiveScope.Model is TypeModel typeModel
- && ReferenceEquals(typeModel.Metadata, member.DeclaringType)
- ? NameQualifier.None
- : NameQualifier.DeclaringType;
+ // Operators and extension methods do not require qualification
+ if (member is IOperator or IWithExtensionBehavior { IsExtension: true })
+ return NameQualifier.None;
+
+ // Otherwise, qualify with declaring type
+ return NameQualifier.DeclaringType;
+
+ bool IsMemberInCurrentTypeScope() => context.AddressProvider.ActiveScope.Model switch
+ {
+ TypeModel typeModel => ReferenceEquals(typeModel.Metadata, member.DeclaringType),
+ MemberModel memberModel => ReferenceEquals(memberModel.Metadata.DeclaringType, member.DeclaringType),
+ _ => false,
+ };
}
}
}
diff --git a/src/Kampute.DocToolkit.csproj b/src/Kampute.DocToolkit.csproj
index f0031839..65838d1e 100644
--- a/src/Kampute.DocToolkit.csproj
+++ b/src/Kampute.DocToolkit.csproj
@@ -4,7 +4,7 @@
netstandard2.1
Kampute.DocDotLib
Provides extensible pipeline for generating .NET API documentation by transforming assembly metadata and XML documentation into structured models, with automatic cross-reference resolution, support for multiple output formats (HTML, Markdown), and integration of conceptual topics.
- 2.0.1
+ 2.1.0
Kampute
Kambiz Khojasteh
Copyright (c) 2025 Kampute
diff --git a/src/Routing/UrlReference.cs b/src/Routing/UrlReference.cs
new file mode 100644
index 00000000..746b17f4
--- /dev/null
+++ b/src/Routing/UrlReference.cs
@@ -0,0 +1,105 @@
+// Copyright (C) 2025 Kampute
+//
+// Released under the terms of the MIT license.
+// See the LICENSE file in the project root for the full license text.
+
+namespace Kampute.DocToolkit.Routing
+{
+ using System;
+
+ ///
+ /// Represents a non-API URL that has been referenced in the documentation.
+ ///
+ public class UrlReference
+ {
+ ///
+ /// Initializes a new instance of the class using the specified scope.
+ ///
+ /// The scope in which the URL is referenced.
+ /// The original URL string from the documentation source (e.g., XML comment or topic).
+ /// The URI corresponding to the in the generated documentation, if available.
+ /// Thrown when or is .
+ /// Thrown when the does not have an associated documentation model.
+ public UrlReference(DocumentUrlContext scope, string sourceUrl, Uri? targetUrl = null)
+ {
+ if (scope is null)
+ throw new ArgumentNullException(nameof(scope));
+
+ ReferencingModel = scope.Model ?? throw new ArgumentException("The scope must have an associated documentation model.", nameof(scope));
+ BaseDirectory = scope.Directory;
+ SourceUrl = sourceUrl ?? throw new ArgumentNullException(nameof(sourceUrl));
+ TargetUrl = targetUrl;
+ }
+
+ ///
+ /// Initializes a new instance of the class using the referencing model and base directory.
+ ///
+ /// The documentation model in which the URL is referenced.
+ /// The directory path of the referencing model's documentation page, relative to the documentation root.
+ /// The original URL string from the documentation source (e.g., XML comment or topic).
+ /// The URI corresponding to the in the generated documentation, if available.
+ /// Thrown when , , or is .
+ public UrlReference(IDocumentModel referencingModel, string baseDirectory, string sourceUrl, Uri? targetUrl = null)
+ {
+ ReferencingModel = referencingModel ?? throw new ArgumentNullException(nameof(referencingModel));
+ BaseDirectory = baseDirectory ?? throw new ArgumentNullException(nameof(baseDirectory));
+ SourceUrl = sourceUrl ?? throw new ArgumentNullException(nameof(sourceUrl));
+ TargetUrl = targetUrl;
+ }
+
+ ///
+ /// Gets the documentation model in which the URL is referenced.
+ ///
+ ///
+ /// The documentation model in which the URL is referenced.
+ ///
+ public IDocumentModel ReferencingModel { get; }
+
+ ///
+ /// Gets the directory path of the referencing model's documentation page, relative to the documentation root.
+ ///
+ ///
+ /// A string representing the relative directory path of the referencing model's documentation page.
+ ///
+ ///
+ /// This path is relative to the root directory of the documentation site.
+ ///
+ /// When the is a relative URL, it is relative to this directory.
+ ///
+ ///
+ public string BaseDirectory { get; }
+
+ ///
+ /// Gets the original URL string from the documentation source (e.g., XML comment or topic).
+ ///
+ ///
+ /// A string representing the source URL.
+ ///
+ public string SourceUrl { get; }
+
+ ///
+ /// Gets the URL corresponding to the in the generated documentation.
+ ///
+ ///
+ /// A representing the target URL, or if the URL could not be resolved.
+ ///
+ ///
+ /// If the could not be resolved to a URL of an internal resource, this property will be .
+ /// Common reasons for a value include:
+ ///
+ /// - The source string is not a well-formed absolute or relative URI.
+ /// - The source contains only a fragment identifier or only a query string with no path to resolve.
+ /// - The source points to a URL outside the scope of the documentation set.
+ ///
+ /// When the is a relative URL, it is relative to the directory of the referencing model's
+ /// documentation page as indicated by the property.
+ ///
+ public Uri? TargetUrl { get; }
+
+ ///
+ /// Returns a string that represents the current .
+ ///
+ /// A string that represents the current .
+ public override string ToString() => SourceUrl;
+ }
+}
\ No newline at end of file
diff --git a/src/Routing/UrlReferenceCollector.cs b/src/Routing/UrlReferenceCollector.cs
new file mode 100644
index 00000000..056f9b1b
--- /dev/null
+++ b/src/Routing/UrlReferenceCollector.cs
@@ -0,0 +1,73 @@
+// Copyright (C) 2025 Kampute
+//
+// Released under the terms of the MIT license.
+// See the LICENSE file in the project root for the full license text.
+
+namespace Kampute.DocToolkit.Routing
+{
+ using Kampute.DocToolkit;
+ using System;
+ using System.Collections.Concurrent;
+ using System.Collections.Generic;
+ using System.Diagnostics.CodeAnalysis;
+
+ ///
+ /// Provides a URL transformer that records all URLs that are processed through it.
+ ///
+ ///
+ /// This class decorates an existing instance, intercepting URL transformation requests
+ /// to record all URLs that are processed. It maintains a collection of instances representing
+ /// the URLs that have been recorded, along with their associated documentation models.
+ ///
+ /// The recorded URLs can be used to validate links, generate reports, or perform other analysis on the URLs referenced
+ /// in the documentation topics or <see> or <seealso> tags with href attributes in XML
+ /// comments.
+ ///
+ ///
+ ///
+ public sealed class UrlReferenceCollector : IUrlTransformer
+ {
+ private readonly IDocumentationContext context;
+ private readonly IUrlTransformer urlTransformer;
+ private readonly ConcurrentBag urls = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The documentation context used to obtain the active model.
+ /// The inner URL transformer to decorate.
+ /// Thrown when or is .
+ public UrlReferenceCollector(IDocumentationContext context, IUrlTransformer urlTransformer)
+ {
+ this.urlTransformer = urlTransformer ?? throw new ArgumentNullException(nameof(urlTransformer));
+ this.context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ ///
+ /// Gets the collection of URLs that have been recorded so far.
+ ///
+ ///
+ /// A read-only collection of instances representing the URLs that have been recorded.
+ ///
+ public IReadOnlyCollection Urls => urls;
+
+ ///
+ ///
+ /// Since class records all URLs that are processed through it, the
+ /// property always returns , regardless of the state of the underlying URL transformer.
+ ///
+ public bool MayTransformUrls => true;
+
+ ///
+ public bool TryTransformUrl(string urlString, [NotNullWhen(true)] out Uri? transformedUrl)
+ {
+ var scope = context.AddressProvider.ActiveScope;
+ var transformed = urlTransformer.TryTransformUrl(urlString, out transformedUrl);
+
+ if (scope.Model is not null)
+ urls.Add(new UrlReference(scope, urlString, transformedUrl));
+
+ return transformed;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/DocumentationContextExtensionsTests.cs b/tests/DocumentationContextExtensionsTests.cs
index f2080b88..a4174412 100644
--- a/tests/DocumentationContextExtensionsTests.cs
+++ b/tests/DocumentationContextExtensionsTests.cs
@@ -8,6 +8,7 @@ namespace Kampute.DocToolkit.Test
using Kampute.DocToolkit;
using Kampute.DocToolkit.Formatters;
using Kampute.DocToolkit.IO.Writers;
+ using Kampute.DocToolkit.Languages;
using Kampute.DocToolkit.Metadata;
using Kampute.DocToolkit.Routing;
using Kampute.DocToolkit.XmlDoc;
@@ -173,6 +174,70 @@ public void TryTransformText_UnsupportedFormat_ReturnsFalse()
}
}
+ [Test]
+ public void DetermineNameQualifier_TypeMember_ReturnsDeclaringType()
+ {
+ using var docContext = MockHelper.CreateDocumentationContext();
+ var typeMock = Mock.Of();
+
+ var result = docContext.DetermineNameQualifier(typeMock);
+
+ Assert.That(result, Is.EqualTo(NameQualifier.DeclaringType));
+ }
+
+ [Test]
+ public void DetermineNameQualifier_OperatorMember_ReturnsNone()
+ {
+ using var docContext = MockHelper.CreateDocumentationContext();
+ var operatorMock = Mock.Of();
+
+ var result = docContext.DetermineNameQualifier(operatorMock);
+
+ Assert.That(result, Is.EqualTo(NameQualifier.None));
+ }
+
+ [Test]
+ public void DetermineNameQualifier_ExtensionMethod_ReturnsNone()
+ {
+ using var docContext = MockHelper.CreateDocumentationContext();
+ var extensionMock = Mock.Of(m => m.IsExtension == true);
+
+ var result = docContext.DetermineNameQualifier(extensionMock);
+
+ Assert.That(result, Is.EqualTo(NameQualifier.None));
+ }
+
+ [Test]
+ public void DetermineNameQualifier_MemberInCurrentTypeScope_ReturnsNone()
+ {
+ var assembly = MockHelper.CreateAssembly("TestAssembly", ["Test.Namespace"]);
+ using var docContext = MockHelper.CreateDocumentationContext(assembly);
+ var typeModel = docContext.Types.First();
+ docContext.AddressProvider.BeginScope("test", typeModel);
+
+ var memberMock = Mock.Of(m => m.DeclaringType == typeModel.Metadata);
+
+ var result = docContext.DetermineNameQualifier(memberMock);
+
+ Assert.That(result, Is.EqualTo(NameQualifier.None));
+ }
+
+ [Test]
+ public void DetermineNameQualifier_MemberNotInCurrentTypeScope_ReturnsDeclaringType()
+ {
+ var assembly = MockHelper.CreateAssembly("TestAssembly", ["Test.Namespace"]);
+ using var docContext = MockHelper.CreateDocumentationContext(assembly);
+ var typeModel = docContext.Types.First();
+ docContext.AddressProvider.BeginScope("test", typeModel);
+
+ var otherType = Mock.Of();
+ var memberMock = Mock.Of(m => m.DeclaringType == otherType);
+
+ var result = docContext.DetermineNameQualifier(memberMock);
+
+ Assert.That(result, Is.EqualTo(NameQualifier.DeclaringType));
+ }
+
private sealed class TestFormatter : IDocumentFormatter
{
public TestFormatter()
diff --git a/tests/Kampute.DocToolkit.Test.csproj b/tests/Kampute.DocToolkit.Test.csproj
index 68ff485f..ce94d7d8 100644
--- a/tests/Kampute.DocToolkit.Test.csproj
+++ b/tests/Kampute.DocToolkit.Test.csproj
@@ -23,7 +23,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/tests/Routing/UrlReferenceCollectorTests.cs b/tests/Routing/UrlReferenceCollectorTests.cs
new file mode 100644
index 00000000..6e676735
--- /dev/null
+++ b/tests/Routing/UrlReferenceCollectorTests.cs
@@ -0,0 +1,186 @@
+// Copyright (C) 2025 Kampute
+//
+// Released under the terms of the MIT license.
+// See the LICENSE file in the project root for the full license text.
+
+namespace Kampute.DocToolkit.Test.Routing
+{
+ using Kampute.DocToolkit.Formatters;
+ using Kampute.DocToolkit.Routing;
+ using Moq;
+ using NUnit.Framework;
+ using System;
+ using System.Linq;
+
+ [TestFixture]
+ public class UrlReferenceCollectorTests
+ {
+ [Test]
+ public void Constructor_WithNullContext_ThrowsArgumentNullException()
+ {
+ var urlTransformerMock = new Mock();
+
+ Assert.That
+ (
+ () => new UrlReferenceCollector(null!, urlTransformerMock.Object),
+ Throws.ArgumentNullException.With.Property("ParamName").EqualTo("context")
+ );
+ }
+
+ [Test]
+ public void Constructor_WithNullUrlTransformer_ThrowsArgumentNullException()
+ {
+ using var context = MockHelper.CreateDocumentationContext();
+
+ Assert.That
+ (
+ () => new UrlReferenceCollector(context, null!),
+ Throws.ArgumentNullException.With.Property("ParamName").EqualTo("urlTransformer")
+ );
+ }
+
+ [Test]
+ public void MayTransformUrls_AlwaysReturnsTrue()
+ {
+ using var context = MockHelper.CreateDocumentationContext();
+ var urlTransformerMock = Mock.Of(m => m.MayTransformUrls == false);
+ var collector = new UrlReferenceCollector(context, urlTransformerMock);
+
+ Assert.That(collector.MayTransformUrls, Is.True);
+ }
+
+ [Test]
+ public void Urls_Initially_ReturnsEmptyCollection()
+ {
+ using var context = MockHelper.CreateDocumentationContext();
+ var urlTransformerMock = new Mock();
+ var collector = new UrlReferenceCollector(context, urlTransformerMock.Object);
+
+ Assert.That(collector.Urls, Is.Empty);
+ }
+
+ [Test]
+ public void TryTransformUrl_WhenUnderlyingTransformerReturnsTrueAndModelIsNull_ReturnsTrueAndDoesNotAddUrlReference()
+ {
+ var expectedTransformedUri = new Uri("../transformed", UriKind.Relative);
+
+ using var context = MockHelper.CreateDocumentationContext();
+
+ var urlTransformerMock = new Mock();
+ urlTransformerMock.Setup(x => x.TryTransformUrl("test", out It.Ref.IsAny))
+ .Returns((string url, out Uri? uri) =>
+ {
+ uri = expectedTransformedUri;
+ return true;
+ });
+
+ var collector = new UrlReferenceCollector(context, urlTransformerMock.Object);
+
+ using var _ = context.AddressProvider.BeginScope("dir", null);
+
+ var result = collector.TryTransformUrl("test", out var transformedUrl);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(result, Is.True);
+ Assert.That(transformedUrl, Is.EqualTo(expectedTransformedUri));
+ Assert.That(collector.Urls, Is.Empty);
+ }
+ }
+
+ [Test]
+ public void TryTransformUrl_WhenUnderlyingTransformerReturnsTrueAndModelIsNotNull_ReturnsTrueAndAddsUrlReference()
+ {
+ var expectedTransformedUri = new Uri("../transformed", UriKind.Relative);
+
+ var model = Mock.Of();
+ using var context = MockHelper.CreateDocumentationContext();
+
+ var urlTransformerMock = new Mock();
+ urlTransformerMock.Setup(x => x.TryTransformUrl("test", out It.Ref.IsAny))
+ .Returns((string url, out Uri? uri) =>
+ {
+ uri = expectedTransformedUri;
+ return true;
+ });
+
+ var collector = new UrlReferenceCollector(context, urlTransformerMock.Object);
+
+ using var _ = context.AddressProvider.BeginScope("dir", model);
+
+ var result = collector.TryTransformUrl("test", out var transformedUrl);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(result, Is.True);
+ Assert.That(transformedUrl, Is.EqualTo(expectedTransformedUri));
+ Assert.That(collector.Urls, Has.Count.EqualTo(1));
+
+ var urlReference = collector.Urls.First();
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(urlReference.ReferencingModel, Is.SameAs(model));
+ Assert.That(urlReference.BaseDirectory, Is.EqualTo("dir"));
+ Assert.That(urlReference.SourceUrl, Is.EqualTo("test"));
+ Assert.That(urlReference.TargetUrl, Is.EqualTo(expectedTransformedUri));
+ }
+ }
+ }
+
+ [Test]
+ public void TryTransformUrl_WhenUnderlyingTransformerReturnsFalseAndModelIsNull_ReturnsFalseAndDoesNotAddUrlReference()
+ {
+ using var context = MockHelper.CreateDocumentationContext();
+
+ var urlTransformerMock = new Mock();
+ urlTransformerMock.Setup(x => x.TryTransformUrl("test", out It.Ref.IsAny)).Returns(false);
+
+ var collector = new UrlReferenceCollector(context, urlTransformerMock.Object);
+
+ using var _ = context.AddressProvider.BeginScope("dir", null);
+
+ var result = collector.TryTransformUrl("test", out var transformedUrl);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(result, Is.False);
+ Assert.That(transformedUrl, Is.Null);
+ Assert.That(collector.Urls, Is.Empty);
+ }
+ }
+
+ [Test]
+ public void TryTransformUrl_WhenUnderlyingTransformerReturnsFalseAndModelIsNotNull_ReturnsFalseAndAddsUrlReference()
+ {
+ var expectedTransformedUri = new Uri("../transformed", UriKind.Relative);
+
+ var model = Mock.Of();
+ using var context = MockHelper.CreateDocumentationContext();
+
+ var urlTransformerMock = new Mock();
+ urlTransformerMock.Setup(x => x.TryTransformUrl("test", out It.Ref.IsAny)).Returns(false);
+
+ var collector = new UrlReferenceCollector(context, urlTransformerMock.Object);
+
+ using var _ = context.AddressProvider.BeginScope("dir", model);
+
+ var result = collector.TryTransformUrl("test", out var transformedUrl);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(result, Is.False);
+ Assert.That(transformedUrl, Is.Null);
+ Assert.That(collector.Urls, Has.Count.EqualTo(1));
+
+ var urlReference = collector.Urls.First();
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(urlReference.ReferencingModel, Is.SameAs(model));
+ Assert.That(urlReference.BaseDirectory, Is.EqualTo("dir"));
+ Assert.That(urlReference.SourceUrl, Is.EqualTo("test"));
+ Assert.That(urlReference.TargetUrl, Is.Null);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file