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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ Add this to your project’s `.csproj`:
| `Xml2Doc_SingleFile` | Combine all output into a single Markdown file |
| `Xml2Doc_OutputFile` | Path for the merged Markdown file |
| `Xml2Doc_OutputDir` | Directory for per-type docs |
| `Xml2Doc_GenerateIndex` | Generate `index.md` in per-type mode (default: true) |
| `Xml2Doc_FileNameMode` | `verbatim` or `clean` |
| `Xml2Doc_RootNamespaceToTrim` | Namespace prefix to trim for cleaner names |
| `Xml2Doc_CodeBlockLanguage` | Code block language (`csharp` by default) |
Expand All @@ -229,6 +230,11 @@ Example configuration:
</PropertyGroup>
```

When multiple projects intentionally write per-type pages to the same output directory, only one
invocation may own `index.md`. Set `<Xml2Doc_GenerateIndex>false</Xml2Doc_GenerateIndex>` on each
independent project and generate the repository-level index in a separate aggregation step. Xml2Doc
does not currently merge indexes produced by concurrent project builds.

---

## 🧪 Testing
Expand Down
5 changes: 4 additions & 1 deletion Xml2Doc/src/Xml2Doc.Cli/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Xml2Doc.Cli
/// <c>--xml</c>, <c>--out</c>, <c>--single</c>, <c>--file-names</c>, <c>--rootns</c>, <c>--lang</c>,
/// <c>--trim-rootns-filenames</c>, <c>--report</c>, <c>--dry-run</c>, <c>--diff</c>,
/// <c>--anchor-algorithm</c>, <c>--template</c>, <c>--front-matter</c>, <c>--auto-link</c>,
/// <c>--alias-map</c>, <c>--external-docs</c>, <c>--toc</c>, <c>--namespace-index</c>, <c>--parallel</c>.
/// <c>--alias-map</c>, <c>--external-docs</c>, <c>--toc</c>, <c>--namespace-index</c>, <c>--no-index</c>, <c>--parallel</c>.
/// </remarks>
public sealed class CliConfig
{
Expand Down Expand Up @@ -73,6 +73,9 @@ public sealed class CliConfig
/// <summary>Emit namespace index when true. Maps to <c>--namespace-index</c>.</summary>
public bool? NamespaceIndex { get; set; }

/// <summary>Emit the per-type <c>index.md</c>. Defaults to true. Maps inversely to <c>--no-index</c>.</summary>
public bool? GenerateIndex { get; set; }

/// <summary>Max parallelism (less than or equal to 0 or null uses default heuristic). Maps to --parallel option.</summary>
public int? Parallel { get; set; }

Expand Down
8 changes: 7 additions & 1 deletion Xml2Doc/src/Xml2Doc.Cli/xml2doc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ public static int Main(string[] args)
string? externalDocs = null;
bool toc = false;
bool namespaceIndex = false;
bool generateIndex = true;
int? parallel = null;
bool? basenameOnly = false;
string? configPath = null;
Expand Down Expand Up @@ -114,6 +115,7 @@ public static int Main(string[] args)
case "--external-docs" when i + 1 < args.Length: externalDocs = args[++i]; break;
case "--toc": toc = true; break;
case "--namespace-index": namespaceIndex = true; break;
case "--no-index": generateIndex = false; break;
case "--basename-only": basenameOnly = true; break;
case "--parallel" when i + 1 < args.Length:
if (int.TryParse(args[++i], out var p)) parallel = p;
Expand Down Expand Up @@ -154,6 +156,7 @@ public static int Main(string[] args)
if (!string.IsNullOrWhiteSpace(cfg?.ExternalDocs)) externalDocs = externalDocs ?? cfg.ExternalDocs!;
if (cfg?.Toc is bool tc) toc = tc || toc;
if (cfg?.NamespaceIndex is bool ni) namespaceIndex = ni || namespaceIndex;
if (cfg?.GenerateIndex is bool gi && generateIndex) generateIndex = gi;
if (cfg?.BasenameOnly is bool bo) basenameOnly = basenameOnly ?? bo;
if (cfg?.Parallel is int pi && parallel is null) parallel = pi;
if (cfg?.Diff is bool df) diff = df || diff;
Expand Down Expand Up @@ -193,7 +196,8 @@ public static int Main(string[] args)
EmitToc: toc,
EmitNamespaceIndex: namespaceIndex,
BasenameOnly: basenameOnly ?? false,
ParallelDegree: parallel
ParallelDegree: parallel,
GenerateIndex: generateIndex
);

var renderer = new MarkdownRenderer(model, options);
Expand Down Expand Up @@ -258,6 +262,7 @@ public static int Main(string[] args)
externalDocs,
toc,
namespaceIndex,
generateIndex,
basenameOnly = options.BasenameOnly,
parallel
},
Expand Down Expand Up @@ -342,6 +347,7 @@ private static void PrintHelp()
Console.WriteLine(" [--external-docs <url|mapfile>]");
Console.WriteLine(" [--toc]");
Console.WriteLine(" [--namespace-index]");
Console.WriteLine(" [--no-index]");
Console.WriteLine(" [--basename-only]");
Console.WriteLine(" [--parallel <N>]");
Console.WriteLine(" [--config <file>]");
Expand Down
11 changes: 7 additions & 4 deletions Xml2Doc/src/Xml2Doc.Core/MarkdownRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public MarkdownRenderer(Models.Xml2Doc model, RendererOptions? options = null)
// === Public APIs ===

/// <summary>
/// Emits one Markdown file per documented type plus an <c>index.md</c>. Optionally emits namespace index pages.
/// Emits one Markdown file per documented type and, by default, an <c>index.md</c>. Optionally emits namespace index pages.
/// </summary>
/// <param name="outDir">Destination directory (created if absent).</param>
/// <remarks>
Expand Down Expand Up @@ -125,7 +125,8 @@ public void RenderToDirectory(string outDir)
var file = Path.Combine(outDir, FileNameForPerType(t.Id));
File.WriteAllText(file, RenderType(t, includeHeader: true));
}
File.WriteAllText(Path.Combine(outDir, "index.md"), RenderIndex(types, useAnchors: false));
if (_opt.GenerateIndex)
File.WriteAllText(Path.Combine(outDir, "index.md"), RenderIndex(types, useAnchors: false));
Comment thread
jeffpatton1971 marked this conversation as resolved.

if (_opt.EmitNamespaceIndex)
{
Expand Down Expand Up @@ -384,7 +385,8 @@ static string GroupKey(XMember mm)
/// <param name="singleFilePath">If non-null, plans single-file output; otherwise multi‑file.</param>
/// <returns>Absolute paths of files that would be produced.</returns>
/// <remarks>
/// Multi‑file mode always includes <c>index.md</c>. Namespace index emission adds <c>namespaces.md</c> and one page per namespace.
/// Multi‑file mode includes <c>index.md</c> when <see cref="RendererOptions.GenerateIndex"/> is true.
/// Namespace index emission adds <c>namespaces.md</c> and one page per namespace.
/// </remarks>
public IReadOnlyList<string> PlanOutputs(string outDir, string? singleFilePath = null)
{
Expand All @@ -404,7 +406,8 @@ public IReadOnlyList<string> PlanOutputs(string outDir, string? singleFilePath =
list.Add(Path.Combine(root, name));
}

list.Add(Path.Combine(root, "index.md"));
if (_opt.GenerateIndex)
list.Add(Path.Combine(root, "index.md"));
Comment thread
jeffpatton1971 marked this conversation as resolved.

if (_opt.EmitNamespaceIndex)
{
Expand Down
1 change: 1 addition & 0 deletions Xml2Doc/src/Xml2Doc.Core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Now multi-targeted and verified for consistent output across modern .NET TFMs.
- Filename mode: `Verbatim` or `CleanGenerics`
- `RootNamespaceToTrim` (display-only trimming)
- Code block language (default `csharp`)
- Per-type `index.md` generation (`GenerateIndex`, default `true`)
- Output mode (single vs. multi-file)

## Supported Target Frameworks
Expand Down
7 changes: 6 additions & 1 deletion Xml2Doc/src/Xml2Doc.Core/RendererOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ public enum AnchorAlgorithm
/// <param name="ParallelDegree">
/// Max parallelism for rendering; <see langword="null"/> or &lt;= 0 selects a heuristic (typically <c>Environment.ProcessorCount</c>).
/// </param>
/// <param name="GenerateIndex">
/// When true, per-type output includes <c>index.md</c>. Disable this when multiple independent
/// invocations intentionally share one output directory and index ownership is handled separately.
/// </param>
/// <remarks>
/// Example:
/// <code><![CDATA[
Expand Down Expand Up @@ -135,6 +139,7 @@ public sealed record RendererOptions(
bool EmitToc = false,
bool EmitNamespaceIndex = false,
bool BasenameOnly = false,
int? ParallelDegree = null
int? ParallelDegree = null,
bool GenerateIndex = true
);
}
18 changes: 11 additions & 7 deletions Xml2Doc/src/Xml2Doc.MSBuild/GenerateMarkdownFromXmlDoc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ public class GenerateMarkdownFromXmlDoc : Microsoft.Build.Utilities.Task
/// </summary>
public bool EmitNamespaceIndex { get; set; }

/// <summary>
/// When true, per-type output includes <c>index.md</c>. Set false for projects that share an
/// output directory and delegate index ownership to a separate aggregation step. Defaults to true.
/// </summary>
public bool GenerateIndex { get; set; } = true;

/// <summary>
/// When true, uses only the basename for file references (omits directory paths).
/// </summary>
Expand Down Expand Up @@ -231,7 +237,8 @@ public override bool Execute()
EmitToc: EmitToc,
EmitNamespaceIndex: EmitNamespaceIndex,
BasenameOnly: BasenameOnly,
AnchorAlgorithm: anchorAlgEnum
AnchorAlgorithm: anchorAlgEnum,
GenerateIndex: GenerateIndex
);

var renderer = new MarkdownRenderer(model, options);
Expand Down Expand Up @@ -279,12 +286,9 @@ public override bool Execute()
DidWork = true;
}

if (Directory.Exists(outDir))
{
GeneratedFiles = Directory.GetFiles(outDir, "*.md", SearchOption.TopDirectoryOnly)
.Select(p => (ITaskItem)new TaskItem(p))
.ToArray();
}
GeneratedFiles = renderer.PlanOutputs(outDir)
.Select(p => (ITaskItem)new TaskItem(p))
.ToArray();

Log.LogMessage(MessageImportance.High, $"Xml2Doc {(DryRun ? "[dry-run] would write" : "wrote")} Markdown files to {outDir}");
}
Expand Down
12 changes: 12 additions & 0 deletions Xml2Doc/src/Xml2Doc.MSBuild/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ That’s it—on successful build, docs are generated according to the propertie
| `Xml2Doc_SingleFile` | `true` = generate one combined Markdown file; `false` = per-type files. |
| `Xml2Doc_OutputFile` | Output file path when `SingleFile=true` (e.g. `$(ProjectDir)docs\api.md`). |
| `Xml2Doc_OutputDir` | Output directory when `SingleFile=false` (e.g. `$(ProjectDir)docs`). |
| `Xml2Doc_GenerateIndex` | Generate `index.md` in per-type mode. Default: `true`. |
| `Xml2Doc_FileNameMode` | `verbatim` (keep generic arity) or `clean` (friendly generic names). |
| `Xml2Doc_RootNamespaceToTrim` | Optional namespace prefix trimmed from display names. |
| `Xml2Doc_CodeBlockLanguage` | Code block language for fenced blocks (default `csharp`). |
Expand All @@ -62,6 +63,17 @@ That’s it—on successful build, docs are generated according to the propertie
</PropertyGroup>
```

**Shared output directories:** independent project builds cannot safely merge the same `index.md`.
When projects share `Xml2Doc_OutputDir`, set `Xml2Doc_GenerateIndex` to `false` for those projects
and create the repository-level index in a separate aggregation step:

```xml
<PropertyGroup>
<Xml2Doc_OutputDir>$(SolutionDir)docs</Xml2Doc_OutputDir>
<Xml2Doc_GenerateIndex>false</Xml2Doc_GenerateIndex>
</PropertyGroup>
```

**Per-type files (good for large APIs)**

```xml
Expand Down
1 change: 1 addition & 0 deletions Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.props
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<Xml2Doc_Toc>false</Xml2Doc_Toc>
<Xml2Doc_NamespaceIndex>false</Xml2Doc_NamespaceIndex>
<Xml2Doc_BasenameOnly>false</Xml2Doc_BasenameOnly>
<Xml2Doc_GenerateIndex Condition="'$(Xml2Doc_GenerateIndex)'==''">true</Xml2Doc_GenerateIndex>

<Xml2Doc_ReportIncludeTimestamp Condition="'$(Xml2Doc_ReportIncludeTimestamp)'==''">false</Xml2Doc_ReportIncludeTimestamp>

Expand Down
5 changes: 3 additions & 2 deletions Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.targets
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
</GetFileHash>

<PropertyGroup>
<_Xml2Doc_Options>$(Xml2Doc_SingleFile)|$(Xml2Doc_OutputFile)|$(Xml2Doc_OutputDir)|$(Xml2Doc_FileNameMode)|$(Xml2Doc_RootNamespaceToTrim)|$(Xml2Doc_CodeBlockLanguage)|$(Xml2Doc_Toc)|$(Xml2Doc_NamespaceIndex)|$(Xml2Doc_BasenameOnly)|$(Xml2Doc_TrimRootNamespaceInFileNames)|$(Xml2Doc_AnchorAlgorithm)</_Xml2Doc_Options>
<_Xml2Doc_Options>$(Xml2Doc_SingleFile)|$(Xml2Doc_OutputFile)|$(Xml2Doc_OutputDir)|$(Xml2Doc_FileNameMode)|$(Xml2Doc_RootNamespaceToTrim)|$(Xml2Doc_CodeBlockLanguage)|$(Xml2Doc_Toc)|$(Xml2Doc_NamespaceIndex)|$(Xml2Doc_BasenameOnly)|$(Xml2Doc_TrimRootNamespaceInFileNames)|$(Xml2Doc_AnchorAlgorithm)|$(Xml2Doc_GenerateIndex)</_Xml2Doc_Options>
</PropertyGroup>

<ItemGroup>
Expand Down Expand Up @@ -142,6 +142,7 @@
Diff="$(Xml2Doc_Diff)"
EmitToc="$(Xml2Doc_Toc)"
EmitNamespaceIndex="$(Xml2Doc_NamespaceIndex)"
GenerateIndex="$(Xml2Doc_GenerateIndex)"
BasenameOnly="$(Xml2Doc_BasenameOnly)"
AnchorAlgorithm="$(Xml2Doc_AnchorAlgorithm)"
Fingerprint="$(_Xml2Doc_Fingerprint)"
Expand All @@ -162,4 +163,4 @@
Condition="'$(Xml2Doc_DidWork)'=='true' or !Exists('$(Xml2Doc_OutputStamp)')" />
</Target>

</Project>
</Project>
101 changes: 101 additions & 0 deletions Xml2Doc/tests/Xml2Doc.Tests/GenerateMarkdownFromXmlDocTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using Microsoft.Build.Framework;
using Shouldly;
using System;
using System.Collections;
using System.IO;
using System.Linq;
using Xml2Doc.Core;
using Xml2Doc.MSBuild;
using Xunit;

namespace Xml2Doc.Tests
{
public class GenerateMarkdownFromXmlDocTests
{
private sealed class TestBuildEngine : IBuildEngine
{
public bool ContinueOnError => false;
public int LineNumberOfTaskNode => 0;
public int ColumnNumberOfTaskNode => 0;
public string ProjectFileOfTaskNode => string.Empty;

public void LogErrorEvent(BuildErrorEventArgs e) { }
public void LogWarningEvent(BuildWarningEventArgs e) { }
public void LogMessageEvent(BuildMessageEventArgs e) { }
public void LogCustomEvent(CustomBuildEventArgs e) { }

public bool BuildProjectFile(
string projectFileName,
string[] targetNames,
IDictionary globalProperties,
IDictionary targetOutputs) => true;
}

private static readonly string SampleXml =
Path.Combine(
AppContext.BaseDirectory,
"Xml2Doc.Sample.xml");
Comment thread
jeffpatton1971 marked this conversation as resolved.
Dismissed

private static RendererOptions DefaultOptions() => new(
FileNameMode: FileNameMode.CleanGenerics,
RootNamespaceToTrim: "Xml2Doc.Sample",
CodeBlockLanguage: "csharp",
TrimRootNamespaceInFileNames: true
);


[Theory]
[InlineData(true)]
[InlineData(false)]
public void PerType_DryRun_ReportsPlannedFilesWithoutWriting(bool generateIndex)
{
var outDir = Path.Combine(
Path.GetTempPath(),
"Xml2Doc.Tests",
Path.GetRandomFileName());
Comment thread
jeffpatton1971 marked this conversation as resolved.
Dismissed

var task = new GenerateMarkdownFromXmlDoc
{
BuildEngine = new TestBuildEngine(),
XmlPath = SampleXml,
OutputDirectory = outDir,
SingleFile = false,
DryRun = true,
GenerateIndex = generateIndex,
FileNameMode = "clean",
RootNamespaceToTrim = "Xml2Doc.Sample",
TrimRootNamespaceInFileNames = true
};

task.Execute().ShouldBeTrue();
task.DidWork.ShouldBeFalse();

var model = Xml2Doc.Core.Models.Xml2Doc.Load(SampleXml);
var renderer = new MarkdownRenderer(
model,
DefaultOptions() with { GenerateIndex = generateIndex });

var expected = renderer.PlanOutputs(outDir)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();

var actual = task.GeneratedFiles
.Select(item => item.ItemSpec)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();

actual.ShouldBe(expected);

actual.Any(path =>
string.Equals(
Path.GetFileName(path),
"index.md",
StringComparison.OrdinalIgnoreCase))
.ShouldBe(generateIndex);

Directory.Exists(outDir).ShouldBeFalse(
"Dry-run must not create the output directory or Markdown files.");
}

}
}
19 changes: 18 additions & 1 deletion Xml2Doc/tests/Xml2Doc.Tests/RenderSnapshots.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,23 @@ public async Task PerType_CleanNames_Basic()
}
}

[Fact]
public void PerType_GenerateIndexFalse_OmitsIndexFromPlanAndOutput()
{
var model = LoadFixtureModel();
var options = DefaultOptions() with { GenerateIndex = false };
var renderer = new MarkdownRenderer(model, options);
var outDir = Path.Combine(Path.GetTempPath(), "Xml2Doc.Tests", Path.GetRandomFileName());
Comment thread
jeffpatton1971 marked this conversation as resolved.

var planned = renderer.PlanOutputs(outDir);
planned.ShouldNotContain(Path.Combine(Path.GetFullPath(outDir), "index.md"));
Comment thread
jeffpatton1971 marked this conversation as resolved.

renderer.RenderToDirectory(outDir);

File.Exists(Path.Combine(outDir, "index.md")).ShouldBeFalse();
Comment thread
jeffpatton1971 marked this conversation as resolved.
Directory.GetFiles(outDir, "*.md", SearchOption.TopDirectoryOnly).ShouldNotBeEmpty();
}

[Fact]
public async Task Generic_BraceHandling_IsClean()
{
Expand Down Expand Up @@ -201,4 +218,4 @@ public async Task Generic_BraceHandling_IsClean()

private static string Normalize(string s) =>
s.Replace("\r\n", "\n").Trim();
}
}
3 changes: 3 additions & 0 deletions Xml2Doc/tests/Xml2Doc.Tests/Xml2Doc.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Build.Framework" Version="17.11.*" PrivateAssets="all" />
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.11.*" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Xml2Doc.Core\Xml2Doc.Core.csproj" />
<ProjectReference Include="..\..\src\Xml2Doc.MSBuild\Xml2Doc.MSBuild.csproj" />
<ProjectReference Include="..\Xml2Doc.Sample\Xml2Doc.Sample.csproj" />
</ItemGroup>
</Project>
Loading