LANCommander/LANCommander.PluginDocsGenerator/Program.cs
2026-07-28 19:44:59 -05:00

206 lines
6.9 KiB
C#

using System.Reflection;
using System.Text;
using LANCommander.Launcher.Plugins.Extensions;
using LANCommander.PluginDocsGenerator;
using LANCommander.SDK.Plugins;
// Generates the plugin API reference for the documentation site directly from the plugin contract
// assemblies and their XML doc comments. Run with:
// dotnet run --project LANCommander.PluginDocsGenerator [output-path]
// If no output path is supplied, the generator writes to LANCommander.Documentation/Plugins/API Reference.md.
// Anchor types pull in the two assemblies that make up the public plugin surface.
var assemblies = new[]
{
typeof(IPlugin).Assembly, // LANCommander.SDK (LANCommander.SDK.Plugins.*)
typeof(INavigationPageExtension).Assembly, // LANCommander.Launcher.Plugins.*
};
// Only types in these namespaces are considered part of the plugin surface.
string[] namespacePrefixes =
{
"LANCommander.SDK.Plugins",
"LANCommander.Launcher.Plugins",
};
// Fixed ordering so the reference reads top-down from "what you implement" to host internals.
string[] namespaceOrder =
{
"LANCommander.SDK.Plugins",
"LANCommander.SDK.Plugins.Events",
"LANCommander.Launcher.Plugins.Extensions",
"LANCommander.Launcher.Plugins",
};
var docs = new XmlDocLookup(assemblies);
var types = assemblies
.SelectMany(a => a.GetExportedTypes())
.Where(t => t.Namespace is not null && namespacePrefixes.Any(p => t.Namespace == p || t.Namespace.StartsWith(p + ".")))
.Where(t => !t.IsNested)
.ToList();
var sb = new StringBuilder();
sb.AppendLine("---");
sb.AppendLine("title: API Reference");
sb.AppendLine("sidebar_label: API Reference");
sb.AppendLine("sidebar_position: 4");
sb.AppendLine("---");
sb.AppendLine();
sb.AppendLine("{/* This file is generated by LANCommander.PluginDocsGenerator. Do not edit by hand. */}");
sb.AppendLine("{/* Regenerate with: dotnet run --project LANCommander.PluginDocsGenerator */}");
sb.AppendLine();
sb.AppendLine("# Plugin API Reference");
sb.AppendLine();
sb.AppendLine("This reference is generated directly from the plugin contract assemblies and their XML");
sb.AppendLine("documentation comments, so it always reflects the extension surface of the installed version.");
sb.AppendLine("Types are grouped by namespace. Interfaces you implement in a plugin are listed first within");
sb.AppendLine("each group.");
sb.AppendLine();
foreach (var ns in types.Select(t => t.Namespace!).Distinct().OrderBy(NamespaceRank).ThenBy(n => n))
{
sb.AppendLine($"## `{ns}`");
sb.AppendLine();
var nsTypes = types
.Where(t => t.Namespace == ns)
.OrderBy(KindRank)
.ThenBy(t => t.Name, StringComparer.Ordinal);
foreach (var type in nsTypes)
WriteType(sb, type, docs);
}
var outputPath = args.Length > 0 ? args[0] : ResolveDefaultOutputPath();
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
File.WriteAllText(outputPath, sb.ToString());
Console.WriteLine($"Wrote {types.Count} types to {outputPath}");
return;
int NamespaceRank(string ns)
{
var index = Array.IndexOf(namespaceOrder, ns);
return index < 0 ? int.MaxValue : index;
}
static int KindRank(Type t) => t switch
{
{ IsInterface: true } => 0,
{ IsEnum: true } => 3,
{ IsValueType: true } => 2,
_ => 1,
};
static void WriteType(StringBuilder sb, Type type, XmlDocLookup docs)
{
sb.AppendLine($"### {type.Name}");
sb.AppendLine();
sb.AppendLine($"`{Kind(type)}` — `{type.FullName}`");
sb.AppendLine();
var summary = docs.GetSummary(XmlId.ForType(type));
if (summary is not null)
{
sb.AppendLine(summary);
sb.AppendLine();
}
if (type.IsEnum)
{
WriteEnumMembers(sb, type, docs);
return;
}
WriteProperties(sb, type, docs);
WriteMethods(sb, type, docs);
}
static void WriteEnumMembers(StringBuilder sb, Type type, XmlDocLookup docs)
{
var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static);
if (fields.Length == 0)
return;
sb.AppendLine("| Value | Description |");
sb.AppendLine("| --- | --- |");
foreach (var field in fields)
{
var summary = docs.GetSummary(XmlId.ForField(field))?.Replace("\n", " ") ?? "";
sb.AppendLine($"| `{field.Name}` = `{Convert.ToInt64(field.GetRawConstantValue())}` | {summary} |");
}
sb.AppendLine();
}
static void WriteProperties(StringBuilder sb, Type type, XmlDocLookup docs)
{
var properties = type
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.GetIndexParameters().Length == 0)
.OrderBy(p => p.MetadataToken)
.ToList();
if (properties.Count == 0)
return;
sb.AppendLine("**Properties**");
sb.AppendLine();
foreach (var property in properties)
{
sb.AppendLine($"- `{Signatures.Property(property)}`");
var summary = docs.GetSummary(XmlId.ForProperty(property));
if (summary is not null)
sb.AppendLine($" - {summary.Replace("\n", " ")}");
}
sb.AppendLine();
}
static void WriteMethods(StringBuilder sb, Type type, XmlDocLookup docs)
{
var methods = type
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Where(m => !m.IsSpecialName) // drop property/event accessors, operators
.Where(m => m.DeclaringType != typeof(object))
.Where(m => m.Name is not ("Equals" or "GetHashCode" or "ToString" or "Deconstruct" or "PrintMembers"))
.Where(m => !m.Name.StartsWith('<')) // drop compiler-generated (e.g. records)
.OrderBy(m => m.MetadataToken)
.ToList();
if (methods.Count == 0)
return;
sb.AppendLine("**Methods**");
sb.AppendLine();
foreach (var method in methods)
{
sb.AppendLine($"- `{Signatures.Method(method)}`");
var summary = docs.GetSummary(XmlId.ForMethod(method));
if (summary is not null)
sb.AppendLine($" - {summary.Replace("\n", " ")}");
}
sb.AppendLine();
}
static string Kind(Type t)
{
if (t.IsInterface) return "interface";
if (t.IsEnum) return "enum";
if (t.IsValueType) return "struct";
if (typeof(Attribute).IsAssignableFrom(t)) return "attribute";
if (t.GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Any(m => m.Name == "<Clone>$"))
return "record";
return "class";
}
static string ResolveDefaultOutputPath()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "LANCommander.Documentation")))
dir = dir.Parent;
if (dir is null)
throw new InvalidOperationException("Could not locate the LANCommander.Documentation directory. Pass an output path explicitly.");
return Path.Combine(dir.FullName, "LANCommander.Documentation", "Plugins", "API Reference.md");
}