diff --git a/README.md b/README.md
index 6542357..48bfe35 100644
--- a/README.md
+++ b/README.md
@@ -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) |
@@ -229,6 +230,11 @@ Example configuration:
```
+When multiple projects intentionally write per-type pages to the same output directory, only one
+invocation may own `index.md`. Set `false` 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
diff --git a/Xml2Doc/src/Xml2Doc.Cli/Config.cs b/Xml2Doc/src/Xml2Doc.Cli/Config.cs
index 543bfa6..a7d2df1 100644
--- a/Xml2Doc/src/Xml2Doc.Cli/Config.cs
+++ b/Xml2Doc/src/Xml2Doc.Cli/Config.cs
@@ -15,7 +15,7 @@ namespace Xml2Doc.Cli
/// --xml, --out, --single, --file-names, --rootns, --lang,
/// --trim-rootns-filenames, --report, --dry-run, --diff,
/// --anchor-algorithm, --template, --front-matter, --auto-link,
- /// --alias-map, --external-docs, --toc, --namespace-index, --parallel.
+ /// --alias-map, --external-docs, --toc, --namespace-index, --no-index, --parallel.
///
public sealed class CliConfig
{
@@ -73,6 +73,9 @@ public sealed class CliConfig
/// Emit namespace index when true. Maps to --namespace-index.
public bool? NamespaceIndex { get; set; }
+ /// Emit the per-type index.md. Defaults to true. Maps inversely to --no-index.
+ public bool? GenerateIndex { get; set; }
+
/// Max parallelism (less than or equal to 0 or null uses default heuristic). Maps to --parallel option.
public int? Parallel { get; set; }
diff --git a/Xml2Doc/src/Xml2Doc.Cli/xml2doc.cs b/Xml2Doc/src/Xml2Doc.Cli/xml2doc.cs
index f23a544..abb38c8 100644
--- a/Xml2Doc/src/Xml2Doc.Cli/xml2doc.cs
+++ b/Xml2Doc/src/Xml2Doc.Cli/xml2doc.cs
@@ -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;
@@ -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;
@@ -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;
@@ -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);
@@ -258,6 +262,7 @@ public static int Main(string[] args)
externalDocs,
toc,
namespaceIndex,
+ generateIndex,
basenameOnly = options.BasenameOnly,
parallel
},
@@ -342,6 +347,7 @@ private static void PrintHelp()
Console.WriteLine(" [--external-docs ]");
Console.WriteLine(" [--toc]");
Console.WriteLine(" [--namespace-index]");
+ Console.WriteLine(" [--no-index]");
Console.WriteLine(" [--basename-only]");
Console.WriteLine(" [--parallel ]");
Console.WriteLine(" [--config ]");
diff --git a/Xml2Doc/src/Xml2Doc.Core/MarkdownRenderer.cs b/Xml2Doc/src/Xml2Doc.Core/MarkdownRenderer.cs
index a8ac75c..203282c 100644
--- a/Xml2Doc/src/Xml2Doc.Core/MarkdownRenderer.cs
+++ b/Xml2Doc/src/Xml2Doc.Core/MarkdownRenderer.cs
@@ -96,7 +96,7 @@ public MarkdownRenderer(Models.Xml2Doc model, RendererOptions? options = null)
// === Public APIs ===
///
- /// Emits one Markdown file per documented type plus an index.md. Optionally emits namespace index pages.
+ /// Emits one Markdown file per documented type and, by default, an index.md. Optionally emits namespace index pages.
///
/// Destination directory (created if absent).
///
@@ -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));
if (_opt.EmitNamespaceIndex)
{
@@ -384,7 +385,8 @@ static string GroupKey(XMember mm)
/// If non-null, plans single-file output; otherwise multi‑file.
/// Absolute paths of files that would be produced.
///
- /// Multi‑file mode always includes index.md. Namespace index emission adds namespaces.md and one page per namespace.
+ /// Multi‑file mode includes index.md when is true.
+ /// Namespace index emission adds namespaces.md and one page per namespace.
///
public IReadOnlyList PlanOutputs(string outDir, string? singleFilePath = null)
{
@@ -404,7 +406,8 @@ public IReadOnlyList 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"));
if (_opt.EmitNamespaceIndex)
{
diff --git a/Xml2Doc/src/Xml2Doc.Core/README.md b/Xml2Doc/src/Xml2Doc.Core/README.md
index a9b5095..e5645f9 100644
--- a/Xml2Doc/src/Xml2Doc.Core/README.md
+++ b/Xml2Doc/src/Xml2Doc.Core/README.md
@@ -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
diff --git a/Xml2Doc/src/Xml2Doc.Core/RendererOptions.cs b/Xml2Doc/src/Xml2Doc.Core/RendererOptions.cs
index 1f29f8c..7d4061e 100644
--- a/Xml2Doc/src/Xml2Doc.Core/RendererOptions.cs
+++ b/Xml2Doc/src/Xml2Doc.Core/RendererOptions.cs
@@ -94,6 +94,10 @@ public enum AnchorAlgorithm
///
/// Max parallelism for rendering; or <= 0 selects a heuristic (typically Environment.ProcessorCount).
///
+ ///
+ /// When true, per-type output includes index.md. Disable this when multiple independent
+ /// invocations intentionally share one output directory and index ownership is handled separately.
+ ///
///
/// Example:
///
public bool EmitNamespaceIndex { get; set; }
+ ///
+ /// When true, per-type output includes index.md. Set false for projects that share an
+ /// output directory and delegate index ownership to a separate aggregation step. Defaults to true.
+ ///
+ public bool GenerateIndex { get; set; } = true;
+
///
/// When true, uses only the basename for file references (omits directory paths).
///
@@ -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);
@@ -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}");
}
diff --git a/Xml2Doc/src/Xml2Doc.MSBuild/README.md b/Xml2Doc/src/Xml2Doc.MSBuild/README.md
index 57bfdcd..0c30a45 100644
--- a/Xml2Doc/src/Xml2Doc.MSBuild/README.md
+++ b/Xml2Doc/src/Xml2Doc.MSBuild/README.md
@@ -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`). |
@@ -62,6 +63,17 @@ That’s it—on successful build, docs are generated according to the propertie
```
+**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
+
+ $(SolutionDir)docs
+ false
+
+```
+
**Per-type files (good for large APIs)**
```xml
diff --git a/Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.props b/Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.props
index 618452b..8735ed6 100644
--- a/Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.props
+++ b/Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.props
@@ -41,6 +41,7 @@
false
false
false
+ true
false
diff --git a/Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.targets b/Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.targets
index a22b614..3ce11cd 100644
--- a/Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.targets
+++ b/Xml2Doc/src/Xml2Doc.MSBuild/build/Xml2Doc.MSBuild.targets
@@ -82,7 +82,7 @@
- <_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_SingleFile)|$(Xml2Doc_OutputFile)|$(Xml2Doc_OutputDir)|$(Xml2Doc_FileNameMode)|$(Xml2Doc_RootNamespaceToTrim)|$(Xml2Doc_CodeBlockLanguage)|$(Xml2Doc_Toc)|$(Xml2Doc_NamespaceIndex)|$(Xml2Doc_BasenameOnly)|$(Xml2Doc_TrimRootNamespaceInFileNames)|$(Xml2Doc_AnchorAlgorithm)|$(Xml2Doc_GenerateIndex)
@@ -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)"
@@ -162,4 +163,4 @@
Condition="'$(Xml2Doc_DidWork)'=='true' or !Exists('$(Xml2Doc_OutputStamp)')" />
-
\ No newline at end of file
+
diff --git a/Xml2Doc/tests/Xml2Doc.Tests/GenerateMarkdownFromXmlDocTests.cs b/Xml2Doc/tests/Xml2Doc.Tests/GenerateMarkdownFromXmlDocTests.cs
new file mode 100644
index 0000000..5a11d3f
--- /dev/null
+++ b/Xml2Doc/tests/Xml2Doc.Tests/GenerateMarkdownFromXmlDocTests.cs
@@ -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");
+
+ 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());
+
+ 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.");
+ }
+
+ }
+}
diff --git a/Xml2Doc/tests/Xml2Doc.Tests/RenderSnapshots.cs b/Xml2Doc/tests/Xml2Doc.Tests/RenderSnapshots.cs
index 3c24053..7bc789a 100644
--- a/Xml2Doc/tests/Xml2Doc.Tests/RenderSnapshots.cs
+++ b/Xml2Doc/tests/Xml2Doc.Tests/RenderSnapshots.cs
@@ -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());
+
+ var planned = renderer.PlanOutputs(outDir);
+ planned.ShouldNotContain(Path.Combine(Path.GetFullPath(outDir), "index.md"));
+
+ renderer.RenderToDirectory(outDir);
+
+ File.Exists(Path.Combine(outDir, "index.md")).ShouldBeFalse();
+ Directory.GetFiles(outDir, "*.md", SearchOption.TopDirectoryOnly).ShouldNotBeEmpty();
+ }
+
[Fact]
public async Task Generic_BraceHandling_IsClean()
{
@@ -201,4 +218,4 @@ public async Task Generic_BraceHandling_IsClean()
private static string Normalize(string s) =>
s.Replace("\r\n", "\n").Trim();
-}
\ No newline at end of file
+}
diff --git a/Xml2Doc/tests/Xml2Doc.Tests/Xml2Doc.Tests.csproj b/Xml2Doc/tests/Xml2Doc.Tests/Xml2Doc.Tests.csproj
index 48fe1ca..20ea571 100644
--- a/Xml2Doc/tests/Xml2Doc.Tests/Xml2Doc.Tests.csproj
+++ b/Xml2Doc/tests/Xml2Doc.Tests/Xml2Doc.Tests.csproj
@@ -14,9 +14,12 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/docs/adr/ADR-011-generated-output-ownership.md b/docs/adr/ADR-011-generated-output-ownership.md
new file mode 100644
index 0000000..4165e36
--- /dev/null
+++ b/docs/adr/ADR-011-generated-output-ownership.md
@@ -0,0 +1,40 @@
+# ADR‑011 — Generated Output Ownership and Lifecycle
+
+## Status
+
+Proposed
+
+## Context
+
+Per-type rendering writes type pages and `index.md` into one output directory. When independent
+CLI or MSBuild invocations share that directory, each invocation has only its own XML model and
+cannot deterministically aggregate the other projects' types. Concurrent builds therefore race to
+replace `index.md`, and project-scoped cleanup cannot safely infer ownership of neighboring files.
+
+Generated Markdown is a public output contract. Index ownership and deletion boundaries must be
+explicit before shared-directory aggregation or stale-output pruning can be considered safe.
+
+## Decision
+
+1. Core owns whether per-type rendering generates `index.md` through
+ `RendererOptions.GenerateIndex`. The default remains `true` for backward compatibility.
+2. CLI and MSBuild expose the same option. Independent invocations sharing an output directory must
+ disable index generation and delegate the repository-level index to a separate aggregation step.
+3. A project-scoped invocation owns only the paths in its deterministic output plan. Existing files
+ discovered in the output directory are not implicitly owned by that invocation.
+4. Future stale-output pruning must use an explicit, invocation-scoped manifest. It may delete only
+ paths recorded by the same manifest identity, and only after successful generation.
+5. Manifest and output replacement must be atomic. Reports and dry runs must list planned writes and
+ deletions in ordinal order.
+6. Full multi-input aggregation is a separate capability. It must consume all inputs in one logical
+ operation and produce one canonically ordered index; concurrent last-writer-wins merging is not a
+ supported aggregation strategy.
+
+## Consequences
+
+- Existing single-project behavior is unchanged.
+- Shared-directory consumers gain a deterministic configuration that prevents index corruption, but
+ must provide their own aggregation step when a combined index is required.
+- Host configuration remains a projection of Core behavior rather than host-specific rendering.
+- Safe stale-file cleanup can be implemented without treating hand-authored or other-project files as
+ Xml2Doc-owned content.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 2f9c5a0..684f9d1 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -18,3 +18,4 @@ These records explain **why architectural decisions were made**.
| ADR‑008 | Multi‑target compatibility | Accepted |
| ADR‑009 | Structured diagnostics | Proposed |
| ADR‑010 | Pluggable anchor algorithms | Proposed |
+| ADR‑011 | Generated output ownership | Proposed |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 2e90afb..8d6c10e 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -127,6 +127,7 @@ Focus areas:
• report output
• multi‑target compatibility
• improved test infrastructure
+• explicit ownership controls for shared output directories
Architectural themes:
@@ -137,6 +138,7 @@ Related ADRs:
• ADR‑007 MSBuild incremental generation
• ADR‑008 Multi‑target compatibility
+• ADR‑011 Generated output ownership and lifecycle
---