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
44 changes: 40 additions & 4 deletions src/Collections/TopicCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ public bool TryResolve(string reference, [NotNullWhen(true)] out TopicModel? top

if (TryFindBySubpath(reference, out topic))
return true;

if (TryFindByPartialId(reference, out topic))
return true;
}

topic = null;
Expand Down Expand Up @@ -303,13 +306,11 @@ public bool TryFindBySubpath(string filePath, [NotNullWhen(true)] out TopicModel
if (string.IsNullOrEmpty(filePath))
throw new ArgumentException($"{nameof(filePath)} cannot be null or empty.", nameof(filePath));

topic = null;

if (!PathHelper.TryNormalizePath(filePath, out var subPath))
{
topic = null;
return false;
}

topic = null;
foreach (var (sourceFilePath, fileBasedTopic) in topicsByPath)
{
if (PathHelper.IsSubpath(sourceFilePath, subPath))
Expand All @@ -324,6 +325,41 @@ public bool TryFindBySubpath(string filePath, [NotNullWhen(true)] out TopicModel
topic = fileBasedTopic;
}
}

return topic is not null;
}

/// <summary>
/// Attempts to find a topic in the topic hierarchy by its unqualified identifier.
/// </summary>
/// <param name="id">The unqualified identifier the topic to lookup.</param>
/// <param name="topic">When this method returns, contains the topic that uniquely matches the specified identifier; otherwise, <see langword="null"/> if no match or if ambiguous.</param>
/// <returns><see langword="true"/> if a unique matching topic was found; otherwise, <see langword="false"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="id"/> is <see langword="null"/> or empty.</exception>
public bool TryFindByPartialId(string id, [NotNullWhen(true)] out TopicModel? topic)
{
if (string.IsNullOrEmpty(id))
throw new ArgumentException($"{nameof(id)} cannot be null or empty.", nameof(id));

topic = null;
foreach (var candidate in allTopics.Values)
{
if (!candidate.Id.EndsWith(id, StringComparison.Ordinal))
continue;

if (candidate.Id.Length > id.Length && candidate.Id[candidate.Id.Length - id.Length - 1] != '/')
continue;

if (topic is not null)
{
// Ambiguous match
topic = null;
return false;
}

topic = candidate;
}

return topic is not null;
}

Expand Down
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.1.0</Version>
<Version>2.2.0</Version>
<Company>Kampute</Company>
<Authors>Kambiz Khojasteh</Authors>
<Copyright>Copyright (c) 2025 Kampute</Copyright>
Expand Down
9 changes: 8 additions & 1 deletion src/Routing/UrlReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public UrlReference(IDocumentModel referencingModel, string baseDirectory, strin
/// 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"/>.
/// 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>
Expand All @@ -93,6 +93,13 @@ public UrlReference(IDocumentModel referencingModel, string baseDirectory, strin
/// </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.
/// <para>
/// <note type="caution" title="Caution">
/// A non-<see langword="null"/> <see cref="TargetUrl"/> indicates a resolved URL, but it does not guarantee that the URL points to
/// an existing resource within the documentation. This is because the target resource may not have been generated yet or might not
/// be included in the documentation set at the time of URL resolution.
/// </note>
/// </para>
/// </remarks>
public Uri? TargetUrl { get; }

Expand Down
11 changes: 5 additions & 6 deletions src/XmlDoc/Comments/SeeAlsoComment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,16 @@ public SeeAlsoComment WithResolvedHyperlink(IDocumentationContext context)
if (context is null)
throw new ArgumentNullException(nameof(context));

if (!IsHyperlink || !Uri.TryCreate(Target, UriKind.Relative, out var relativeUrl))
if (IsCodeReference || Uri.IsWellFormedUriString(Target, UriKind.Absolute))
return this;

var href = relativeUrl.ToString();
if (!context.UrlTransformer.TryTransformUrl(Target, out var adjustedUrl))
return this;

if (IsEmpty && context.Topics.TryResolve(UriHelper.GetPathPart(href), out var topic))
if (IsEmpty && context.Topics.TryResolve(UriHelper.GetPathPart(Target), out var topic))
Content.Value = topic.Name;

return context.UrlTransformer.TryTransformUrl(href, out var adjustedUrl)
? new SeeAlsoComment(adjustedUrl.ToString(), Content)
: this;
return new SeeAlsoComment(adjustedUrl.ToString(), Content);
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/XmlDoc/XmlDocToHtmlTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ namespace Kampute.DocToolkit.XmlDoc
/// additional text processing capabilities during the transformation process, such as text escaping, formatting,
/// and whitespace normalization.
/// </remarks>
/// <seealso href="xmldoc-tags/xmldoc-to-html.md"/>
/// <seealso href="xmldoc-tags/xmldoc-to-html"/>
public class XmlDocToHtmlTransformer : XmlDocTransformer
{
/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/XmlDoc/XmlDocToMarkdownTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ namespace Kampute.DocToolkit.XmlDoc
/// <see cref="XsltTextTools"/> and <see cref="XsltMarkdownTools"/> extension methods, which provide specialized
/// formatting for Markdown-specific elements beyond basic text processing.
/// </remarks>
/// <seealso href="xmldoc-tags/xmldoc-to-markdown.md"/>
/// <seealso href="xmldoc-tags/xmldoc-to-markdown"/>
public class XmlDocToMarkdownTransformer : XmlDocTransformer
{
/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions src/Xslt/NamespaceDoc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ namespace Kampute.DocToolkit.Xslt
/// transform XML documentation. These utilities implement a declarative approach to transforming XML documentation
/// into various output formats using XSLT stylesheets.
/// </remarks>
/// <seealso href="xmldoc-to-html"/>
/// <seealso href="xmldoc-to-markdown"/>
/// <seealso href="xmldoc-tags/xmldoc-to-html"/>
/// <seealso href="xmldoc-tags/xmldoc-to-markdown"/>
internal static class NamespaceDoc
{
// This class doesn't contain any code
Expand Down
129 changes: 126 additions & 3 deletions tests/Collections/TopicCollectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,129 @@ public void TryFindBySubpath_WithMultipleMatches_ReturnsNull()
}
}

[Test]
public void TryFindByPartialId_WithNullId_ThrowsArgumentException()
{
var collection = new TopicCollection(context);

Assert.That(() => collection.TryFindByPartialId(null!, out _), Throws.ArgumentException
.With.Property("ParamName").EqualTo("id"));
}

[Test]
public void TryFindByPartialId_WithEmptyId_ThrowsArgumentException()
{
var collection = new TopicCollection(context);

Assert.That(() => collection.TryFindByPartialId("", out _), Throws.ArgumentException
.With.Property("ParamName").EqualTo("id"));
}

[Test]
public void TryFindByPartialId_WithNoMatches_ReturnsFalse()
{
var collection = new TopicCollection(context)
{
CreateMockTopic("Topic1"),
CreateMockTopicWithChildren("Topic2", "Child1")
};

var result = collection.TryFindByPartialId("NonExistent", out var found);

using (Assert.EnterMultipleScope())
{
Assert.That(result, Is.False);
Assert.That(found, Is.Null);
}
}

[Test]
public void TryFindByPartialId_WithExactMatch_ReturnsTrue()
{
var collection = new TopicCollection(context)
{
CreateMockTopic("Topic1"),
CreateMockTopic("Topic2")
};

var result = collection.TryFindByPartialId("Topic1", out var found);

using (Assert.EnterMultipleScope())
{
Assert.That(result, Is.True);
Assert.That(found?.Id, Is.EqualTo("Topic1"));
}
}

[Test]
public void TryFindByPartialId_WithPartialMatch_ReturnsTrue()
{
var collection = new TopicCollection(context)
{
CreateMockTopicWithChildren("Parent", "Child")
};

var result = collection.TryFindByPartialId("Child", out var found);

using (Assert.EnterMultipleScope())
{
Assert.That(result, Is.True);
Assert.That(found?.Id, Is.EqualTo("Parent/Child"));
}
}

[Test]
public void TryFindByPartialId_WithAmbiguousMatches_ReturnsFalse()
{
var collection = new TopicCollection(context)
{
CreateMockTopicWithChildren("Parent1", "Child"),
CreateMockTopicWithChildren("Parent2", "Child")
};

var result = collection.TryFindByPartialId("Child", out var found);

using (Assert.EnterMultipleScope())
{
Assert.That(result, Is.False);
Assert.That(found, Is.Null);
}
}

[Test]
public void TryFindByPartialId_WithNoSeparator_ReturnsFalse()
{
var collection = new TopicCollection(context)
{
CreateMockTopic("ParentChild")
};

var result = collection.TryFindByPartialId("Child", out var found);

using (Assert.EnterMultipleScope())
{
Assert.That(result, Is.False);
Assert.That(found, Is.Null);
}
}

[Test]
public void TryFindByPartialId_WithNestedPartialMatch_ReturnsTrue()
{
var collection = new TopicCollection(context)
{
CreateMockTopic("A/B/C")
};

var result = collection.TryFindByPartialId("C", out var found);

using (Assert.EnterMultipleScope())
{
Assert.That(result, Is.True);
Assert.That(found?.Id, Is.EqualTo("A/B/C"));
}
}

[Test]
public void TryResolve_WithNullReference_ReturnsFalse()
{
Expand Down Expand Up @@ -676,7 +799,7 @@ public void TryResolve_WithRelativeTopicId_ToRoot_ResolvesCorrectly()
}

[Test]
public void TryResolve_WithRelativeTopicId_OutOfScope_ReturnsFalse()
public void TryResolve_WithUniqueRelativeTopicId_OutOfScope_ResolvesCorrectly()
{
var collection = new TopicCollection(context)
{
Expand All @@ -687,8 +810,8 @@ public void TryResolve_WithRelativeTopicId_OutOfScope_ReturnsFalse()

using (Assert.EnterMultipleScope())
{
Assert.That(result, Is.False);
Assert.That(resolvedTopic, Is.Null);
Assert.That(result, Is.True);
Assert.That(resolvedTopic?.Id, Is.EqualTo("guides/installation"));
}
}

Expand Down
Loading