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
4 changes: 2 additions & 2 deletions src/Kampute.DocToolkit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Title>Kampute.DocDotLib</Title>
<Title>Kampute.DocToolkit</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.2.1</Version>
<Version>2.3.0</Version>
<Company>Kampute</Company>
<Authors>Kambiz Khojasteh</Authors>
<Copyright>Copyright (C) Kampute</Copyright>
Expand Down
133 changes: 133 additions & 0 deletions src/Support/Markdown.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
namespace Kampute.DocToolkit.Support
{
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.CompilerServices;

Expand Down Expand Up @@ -211,5 +213,136 @@ public static int GetMinimumFenceBackticks(ReadOnlySpan<char> code)
? maxConsecutiveBackticks + 1
: DefaultFenceBackticks;
}

/// <summary>
/// Enumerates all ATX-style headings in the given Markdown content, returning their text and corresponding levels.
/// </summary>
/// <param name="content">The Markdown content to inspect.</param>
/// <returns>An enumerable of tuples, each containing the level and text of a heading found in the content.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="content"/> is <see langword="null"/>.</exception>
public static IEnumerable<(int Level, string Heading)> EnumerateHeadings(string content)
{
if (content is null)
throw new ArgumentNullException(nameof(content));

var pos = 0;
var length = content.Length;
while (pos < length)
{
var lineStart = pos;
while (pos < length && content[pos] != '\n')
pos++;

var line = content.AsSpan(lineStart, pos - lineStart).Trim();
if (pos < length)
pos++;

if (!line.IsEmpty && line[0] == '#')
{
var level = 1;
while (level < line.Length && line[level] == '#')
level++;

var headingText = line[level..].Trim();
if (!headingText.IsEmpty)
yield return (level, headingText.ToString());
}
}
}

/// <summary>
/// Attempts to find the first ATX-style heading that appears before any non-heading content in the given Markdown text.
/// </summary>
/// <param name="content">The Markdown content to inspect.</param>
/// <param name="heading">When this method returns, contains the heading text if found; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if a heading was found and it appears before any non-heading content; otherwise, <see langword="false"/>.</returns>
public static bool TryGetFirstHeading(ReadOnlySpan<char> content, [NotNullWhen(true)] out string? heading)
{
var pos = 0;
var length = content.Length;
while (pos < length)
{
var lineStart = pos;
while (pos < length && content[pos] != '\n')
pos++;

var line = content[lineStart..pos].Trim();
if (pos < length)
pos++; // Skip the newline character

if (line.IsEmpty)
continue;

if (line[0] != '#')
break;

var level = 1;
while (level < line.Length && line[level] == '#')
level++;

var headingText = line[level..].TrimStart();
if (headingText.IsEmpty)
break;

heading = headingText.ToString();
return true;
}

heading = null;
return false;
}

/// <summary>
/// Attempts to extract and parse the front matter block from Markdown content.
/// </summary>
/// <param name="content">The Markdown content to inspect.</param>
/// <param name="frontMatter">When this method returns, contains the parsed front matter metadata if found; otherwise, an empty dictionary.</param>
/// <param name="contentStart">When this method returns, contains the index in <paramref name="content"/> at which the body content starts.</param>
/// <returns><see langword="true"/> if a front matter block was found and parsed; otherwise, <see langword="false"/>.</returns>
/// <exception cref="FormatException">Thrown when a front matter block is found but cannot be parsed as valid YAML.</exception>
/// <remarks>
/// Front matter must start at the very beginning of the document, delimited by <c>---</c> on its own line.
/// The closing delimiter may be either <c>---</c> or <c>...</c>.
/// </remarks>
public static bool TryExtractFrontMatter(ReadOnlySpan<char> content, out IReadOnlyDictionary<string, object?> frontMatter, out int contentStart)
{
frontMatter = Yaml.Empty;
contentStart = 0;

if (!content.StartsWith("---"))
return false;

var length = content.Length;

var pos = 3;
if (pos < length && content[pos] == '\r')
pos++;
if (pos >= length || content[pos] != '\n')
return false;

pos++;
var yamlStart = pos;

while (pos < length)
{
var lineStart = pos;
while (pos < length && content[pos] != '\n')
pos++;

var lineEnd = (pos > lineStart && content[pos - 1] == '\r') ? pos - 1 : pos;
if (pos < length)
pos++;

var line = content[lineStart..lineEnd];
if (line.Equals("---", StringComparison.Ordinal) || line.Equals("...", StringComparison.Ordinal))
{
frontMatter = Yaml.Parse(content[yamlStart..lineStart].TrimEnd());
contentStart = pos;
return true;
}
}

return false;
}
}
}
Loading
Loading