Skip to content
Merged
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
3 changes: 2 additions & 1 deletion kampose.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 15 additions & 1 deletion src/DocumentationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -216,6 +216,20 @@ protected void Dispose(bool disposing)
/// </remarks>
protected virtual TopicModel ToScopedTopic(ITopic topic) => new(this, topic ?? throw new ArgumentNullException(nameof(topic)));

/// <summary>
/// Creates the URL transformer for the documentation context.
/// </summary>
/// <returns>The URL transformer to use for transforming non-API URLs in the documentation.</returns>
/// <remarks>
/// This method creates an implementation of <see cref="IUrlTransformer"/> for transforming non-API
/// site-root-relative URLs to absolute or document-relative URLs.
/// <para>
/// The default implementation returns an instance of the <see cref="ContextAwareUrlTransformer"/> class.
/// Override this method in derived classes to provide a custom URL transformer implementation if needed.
/// </para>
/// </remarks>
protected virtual IUrlTransformer CreateUrlTransformer() => new ContextAwareUrlTransformer(this);

/// <summary>
/// Retrieves all unique namespaces with exported types from the assemblies in the documentation context.
/// </summary>
Expand Down
21 changes: 16 additions & 5 deletions src/DocumentationContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
}
2 changes: 1 addition & 1 deletion src/Kampute.DocToolkit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>netstandard2.1</TargetFramework>
<Title>Kampute.DocDotLib</Title>
<Description>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.</Description>
<Version>2.0.1</Version>
<Version>2.1.0</Version>
<Company>Kampute</Company>
<Authors>Kambiz Khojasteh</Authors>
<Copyright>Copyright (c) 2025 Kampute</Copyright>
Expand Down
105 changes: 105 additions & 0 deletions src/Routing/UrlReference.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents a non-API URL that has been referenced in the documentation.
/// </summary>
public class UrlReference
{
/// <summary>
/// Initializes a new instance of the <see cref="UrlReference"/> class using the specified scope.
/// </summary>
/// <param name="scope">The scope in which the URL is referenced.</param>
/// <param name="sourceUrl">The original URL string from the documentation source (e.g., XML comment or topic).</param>
/// <param name="targetUrl">The URI corresponding to the <paramref name="sourceUrl"/> in the generated documentation, if available.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="scope"/> or <paramref name="sourceUrl"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when the <paramref name="scope"/> does not have an associated documentation model.</exception>
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;
}

/// <summary>
/// Initializes a new instance of the <see cref="UrlReference"/> class using the referencing model and base directory.
/// </summary>
/// <param name="referencingModel">The documentation model in which the URL is referenced.</param>
/// <param name="baseDirectory">The directory path of the referencing model's documentation page, relative to the documentation root.</param>
/// <param name="sourceUrl">The original URL string from the documentation source (e.g., XML comment or topic).</param>
/// <param name="targetUrl">The URI corresponding to the <paramref name="sourceUrl"/> in the generated documentation, if available.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="referencingModel"/>, <paramref name="baseDirectory"/>, or <paramref name="sourceUrl"/> is <see langword="null"/>.</exception>
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;
}

/// <summary>
/// Gets the documentation model in which the URL is referenced.
/// </summary>
/// <value>
/// The documentation model in which the URL is referenced.
/// </value>
public IDocumentModel ReferencingModel { get; }

/// <summary>
/// Gets the directory path of the referencing model's documentation page, relative to the documentation root.
/// </summary>
/// <value>
/// A string representing the relative directory path of the referencing model's documentation page.
/// </value>
/// <remarks>
/// This path is relative to the root directory of the documentation site.
/// <note type="hint" title="Hint">
/// When the <see cref="TargetUrl"/> is a relative URL, it is relative to this directory.
/// </note>
/// </remarks>
public string BaseDirectory { get; }

/// <summary>
/// Gets the original URL string from the documentation source (e.g., XML comment or topic).
/// </summary>
/// <value>
/// A string representing the source URL.
/// </value>
public string SourceUrl { get; }

/// <summary>
/// Gets the URL corresponding to the <see cref="SourceUrl"/> in the generated documentation.
/// </summary>
/// <value>
/// A <see cref="Uri"/> representing the target URL, or <see langword="null"/> if the URL could not be resolved.
/// </value>
/// <remarks>
/// If the <see cref="SourceUrl"/> could not be resolved to a URL of an internal resource, this property will be <see langword="null"/>.
/// Common reasons for a <see langword="null"/> value include:
/// <list type="bullet">
/// <item><description>The source string is not a well-formed absolute or relative URI.</description></item>
/// <item><description>The source contains only a fragment identifier or only a query string with no path to resolve.</description></item>
/// <item><description>The source points to a URL outside the scope of the documentation set.</description></item>
/// </list>
/// When the <see cref="TargetUrl"/> is a relative URL, it is relative to the directory of the referencing model's
/// documentation page as indicated by the <see cref="BaseDirectory"/> property.
/// </remarks>
public Uri? TargetUrl { get; }

/// <summary>
/// Returns a string that represents the current <see cref="UrlReference"/>.
/// </summary>
/// <returns>A string that represents the current <see cref="UrlReference"/>.</returns>
public override string ToString() => SourceUrl;
}
}
73 changes: 73 additions & 0 deletions src/Routing/UrlReferenceCollector.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides a URL transformer that records all URLs that are processed through it.
/// </summary>
/// <remarks>
/// This class decorates an existing <see cref="IUrlTransformer"/> instance, intercepting URL transformation requests
/// to record all URLs that are processed. It maintains a collection of <see cref="UrlReference"/> instances representing
/// the URLs that have been recorded, along with their associated documentation models.
/// <para>
/// The recorded URLs can be used to validate links, generate reports, or perform other analysis on the URLs referenced
/// in the documentation topics or <c>&lt;see&gt;</c> or <c>&lt;seealso&gt;</c> tags with <c>href</c> attributes in XML
/// comments.
/// </para>
/// </remarks>
/// <threadsafety static="true" instance="true"/>
public sealed class UrlReferenceCollector : IUrlTransformer
{
private readonly IDocumentationContext context;
private readonly IUrlTransformer urlTransformer;
private readonly ConcurrentBag<UrlReference> urls = [];

/// <summary>
/// Initializes a new instance of the <see cref="UrlReferenceCollector"/> class.
/// </summary>
/// <param name="context">The documentation context used to obtain the active model.</param>
/// <param name="urlTransformer">The inner URL transformer to decorate.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="context"/> or <paramref name="urlTransformer"/> is <see langword="null"/>.</exception>
public UrlReferenceCollector(IDocumentationContext context, IUrlTransformer urlTransformer)
{
this.urlTransformer = urlTransformer ?? throw new ArgumentNullException(nameof(urlTransformer));
this.context = context ?? throw new ArgumentNullException(nameof(context));
}

/// <summary>
/// Gets the collection of URLs that have been recorded so far.
/// </summary>
/// <value>
/// A read-only collection of <see cref="UrlReference"/> instances representing the URLs that have been recorded.
/// </value>
public IReadOnlyCollection<UrlReference> Urls => urls;

/// <inheritdoc/>
/// <remarks>
/// Since <see cref="UrlReferenceCollector"/> class records all URLs that are processed through it, the <see cref="MayTransformUrls"/>
/// property always returns <see langword="true"/>, regardless of the state of the underlying URL transformer.
/// </remarks>
public bool MayTransformUrls => true;

/// <inheritdoc/>
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;
}
}
}
65 changes: 65 additions & 0 deletions tests/DocumentationContextExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -173,6 +174,70 @@ public void TryTransformText_UnsupportedFormat_ReturnsFalse()
}
}

[Test]
public void DetermineNameQualifier_TypeMember_ReturnsDeclaringType()
{
using var docContext = MockHelper.CreateDocumentationContext<HtmlFormat>();
var typeMock = Mock.Of<IType>();

var result = docContext.DetermineNameQualifier(typeMock);

Assert.That(result, Is.EqualTo(NameQualifier.DeclaringType));
}

[Test]
public void DetermineNameQualifier_OperatorMember_ReturnsNone()
{
using var docContext = MockHelper.CreateDocumentationContext<HtmlFormat>();
var operatorMock = Mock.Of<IOperator>();

var result = docContext.DetermineNameQualifier(operatorMock);

Assert.That(result, Is.EqualTo(NameQualifier.None));
}

[Test]
public void DetermineNameQualifier_ExtensionMethod_ReturnsNone()
{
using var docContext = MockHelper.CreateDocumentationContext<HtmlFormat>();
var extensionMock = Mock.Of<IMethod>(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<HtmlFormat>(assembly);
var typeModel = docContext.Types.First();
docContext.AddressProvider.BeginScope("test", typeModel);

var memberMock = Mock.Of<IMember>(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<HtmlFormat>(assembly);
var typeModel = docContext.Types.First();
docContext.AddressProvider.BeginScope("test", typeModel);

var otherType = Mock.Of<IType>();
var memberMock = Mock.Of<IMember>(m => m.DeclaringType == otherType);

var result = docContext.DetermineNameQualifier(memberMock);

Assert.That(result, Is.EqualTo(NameQualifier.DeclaringType));
}

private sealed class TestFormatter : IDocumentFormatter
{
public TestFormatter()
Expand Down
2 changes: 1 addition & 1 deletion tests/Kampute.DocToolkit.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="6.0.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading