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