diff --git a/CLAUDE.md b/CLAUDE.md index c47561f0..6ace8cd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ Content engine library targeting .NET 11 / C# 15 with union types. - Test: `dotnet test Pennington.slnx` - Single test: `dotnet test Pennington.slnx --filter "FullyQualifiedName~TestName"` - Run docs site: `dotnet run --project docs/Pennington.Docs` +- CLI verbs (any host): `dotnet run --project ` serves; `-- build [--base-url /x] [--output dir]` generates the static site; `-- diag ` runs read-only inspection (text output) for humans and AI assistants — `-- diag --help` lists them ## Project Structure - `src/Pennington/` — Core library (Markdig, SharpYaml, AngleSharp, TextMateSharp) @@ -36,7 +37,8 @@ Content engine library targeting .NET 11 / C# 15 with union types. - `Pennington.LlmsTxt` — LlmsTxtService, LlmsTxtContentService (llms.txt index + stripped markdown) - `Pennington.StructuredData` — JsonLdSerializer, JsonLdTypes (schema.org) - `Pennington.Diagnostics` — Diagnostic, DiagnosticContext, DiagnosticSeverity (per-request diagnostics) -- `Pennington.Infrastructure` — PenningtonExtensions (AddPennington/UsePennington/RunOrBuildAsync), ResponseProcessingMiddleware, IResponseProcessor, LiveReloadServer +- `Pennington.Infrastructure` — PenningtonExtensions (AddPennington/UsePennington/RunOrBuildAsync), ResponseProcessingMiddleware, IResponseProcessor, LiveReloadServer, PenningtonBuildMode (legacy shim over PenningtonCli) +- `Pennington.Cli` — System.CommandLine host CLI. `PenningtonCli` is the single source of run-mode detection (`PenningtonRunMode` serve/build/diag; `IsHeadlessOneShot`/`WritesOutput` gate TestServer swap, logging, dev overlays). `RunOrBuildAsync` dispatches on it. `IDiagCommand` (DI-discovered) + the `diag` subcommands under `Cli/Diag` (info/toc/routes/warnings/translation/frontmatter/llms), plus `AsciiTreeWriter`. Read-only, text-only output. ## DI Wiring - `services.AddPennington(...)` / `app.UsePennington()` / `app.RunOrBuildAsync(args)` — core diff --git a/Directory.Packages.props b/Directory.Packages.props index ff4f242a..68f8143a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/src/Pennington.Tui/PenningtonTuiHostedService.cs b/src/Pennington.Tui/PenningtonTuiHostedService.cs index 1a2773df..1db8466f 100644 --- a/src/Pennington.Tui/PenningtonTuiHostedService.cs +++ b/src/Pennington.Tui/PenningtonTuiHostedService.cs @@ -39,7 +39,7 @@ public Task StartAsync(CancellationToken cancellationToken) { if (IsBuildMode(Environment.GetCommandLineArgs())) { - logger.LogDebug("Pennington.Tui: build mode detected, TUI disabled"); + logger.LogDebug("Pennington.Tui: headless one-shot command (build/diag) detected, TUI disabled"); return Task.CompletedTask; } @@ -55,10 +55,12 @@ public Task StopAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - // args[0] is the executable; user args start at index 1. Mirrors the gate - // in PenningtonExtensions.RunOrBuildAsync and LiveReloadServer. + // args[0] is the executable; user args start at index 1. The TUI steps aside for any + // headless one-shot verb — build or diag — mirroring PenningtonCli's IsHeadlessOneShot. internal static bool IsBuildMode(string[] commandLineArgs) => - commandLineArgs.Length > 1 && commandLineArgs[1].Equals("build", StringComparison.OrdinalIgnoreCase); + commandLineArgs.Length > 1 + && (commandLineArgs[1].Equals("build", StringComparison.OrdinalIgnoreCase) + || commandLineArgs[1].Equals("diag", StringComparison.OrdinalIgnoreCase)); // dotnet watch sets DOTNET_WATCH=1 in the child process. The TUI grabs the // terminal surface exclusively, which fights with dotnet watch's own output diff --git a/src/Pennington/Cli/AsciiTreeWriter.cs b/src/Pennington/Cli/AsciiTreeWriter.cs new file mode 100644 index 00000000..35ae7715 --- /dev/null +++ b/src/Pennington/Cli/AsciiTreeWriter.cs @@ -0,0 +1,50 @@ +namespace Pennington.Cli; + +/// Renders a hierarchy as an ASCII tree (├─ └─ │) to a . +internal static class AsciiTreeWriter +{ + private const string Tee = "├─ "; + private const string Ell = "└─ "; + private const string Bar = "│ "; + private const string Gap = " "; + + /// + /// Writes as a tree. formats one node to a + /// single line; yields a node's children. Recurses until + /// (1-based; the top level is depth 1). + /// + public static void Write( + TextWriter writer, + IReadOnlyList nodes, + Func label, + Func> children, + int maxDepth = int.MaxValue) + => WriteLevel(writer, nodes, label, children, prefix: "", depth: 1, maxDepth); + + private static void WriteLevel( + TextWriter writer, + IReadOnlyList nodes, + Func label, + Func> children, + string prefix, + int depth, + int maxDepth) + { + for (var i = 0; i < nodes.Count; i++) + { + var isLast = i == nodes.Count - 1; + writer.WriteLine(prefix + (isLast ? Ell : Tee) + label(nodes[i])); + + if (depth >= maxDepth) + { + continue; + } + + var kids = children(nodes[i]); + if (kids.Count > 0) + { + WriteLevel(writer, kids, label, children, prefix + (isLast ? Gap : Bar), depth + 1, maxDepth); + } + } + } +} diff --git a/src/Pennington/Cli/Diag/DiagFrontMatterCommand.cs b/src/Pennington/Cli/Diag/DiagFrontMatterCommand.cs new file mode 100644 index 00000000..cc1727b1 --- /dev/null +++ b/src/Pennington/Cli/Diag/DiagFrontMatterCommand.cs @@ -0,0 +1,109 @@ +namespace Pennington.Cli.Diag; + +using System.CommandLine; +using System.Reflection; +using Content; +using FrontMatter; +using Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +/// diag frontmatter — inventory of front-matter keys accepted by each content type in use. +internal sealed class DiagFrontMatterCommand : IDiagCommand +{ + /// + public string Name => "frontmatter"; + + /// + public string Description => "Inventory the front-matter keys each content type accepts, and how many pages use each type."; + + /// + public Command Build(IServiceProvider services, TextWriter output) + { + var command = new Command(Name, Description); + command.SetAction(async (_, cancellationToken) => + { + var countByType = new Dictionary(); + await foreach (var item in services.GetServices().ParseAllContentAsync(cancellationToken)) + { + var type = item.Metadata.GetType(); + countByType[type] = countByType.GetValueOrDefault(type) + 1; + } + + if (countByType.Count == 0) + { + output.WriteLine("No parseable markdown content found."); + return 0; + } + + var strict = services.GetRequiredService().FrontMatter.StrictUnknownKeys; + output.WriteLine($"Front matter — strict unknown keys: {(strict ? "on" : "off")}"); + output.WriteLine(); + + foreach (var (type, count) in countByType.OrderByDescending(kv => kv.Value).ThenBy(kv => kv.Key.Name, StringComparer.Ordinal)) + { + var capabilities = Capabilities(type); + var capabilityNote = capabilities.Count > 0 ? $" [{string.Join(", ", capabilities)}]" : ""; + output.WriteLine($"{type.Name} ({count} page{(count == 1 ? "" : "s")}){capabilityNote}"); + + foreach (var property in type + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.GetIndexParameters().Length == 0) + .OrderBy(p => p.Name, StringComparer.Ordinal)) + { + output.WriteLine($" {property.Name}: {FriendlyType(property.PropertyType)}"); + } + + output.WriteLine(); + } + + return 0; + }); + return command; + } + + private static List Capabilities(Type type) + { + var capabilities = new List(); + if (typeof(ITaggable).IsAssignableFrom(type)) + { + capabilities.Add("tags"); + } + + if (typeof(IOrderable).IsAssignableFrom(type)) + { + capabilities.Add("order"); + } + + if (typeof(ISectionable).IsAssignableFrom(type)) + { + capabilities.Add("section"); + } + + if (typeof(IRedirectable).IsAssignableFrom(type)) + { + capabilities.Add("redirect"); + } + + return capabilities; + } + + private static string FriendlyType(Type type) + { + var underlying = Nullable.GetUnderlyingType(type); + if (underlying is not null) + { + return FriendlyType(underlying) + "?"; + } + + if (type.IsArray) + { + return FriendlyType(type.GetElementType()!) + "[]"; + } + + return type == typeof(string) ? "string" + : type == typeof(bool) ? "bool" + : type == typeof(int) ? "int" + : type == typeof(DateTime) ? "DateTime" + : type.Name; + } +} diff --git a/src/Pennington/Cli/Diag/DiagInfoCommand.cs b/src/Pennington/Cli/Diag/DiagInfoCommand.cs new file mode 100644 index 00000000..77fade47 --- /dev/null +++ b/src/Pennington/Cli/Diag/DiagInfoCommand.cs @@ -0,0 +1,76 @@ +namespace Pennington.Cli.Diag; + +using System.CommandLine; +using Content; +using Generation; +using Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Navigation; + +/// diag info — a high-level overview of the site: the shape of the app at a glance. +internal sealed class DiagInfoCommand : IDiagCommand +{ + /// + public string Name => "info"; + + /// + public string Description => "Show a high-level overview: title, content roots, pages, sections, locales, and features."; + + /// + public Command Build(IServiceProvider services, TextWriter output) + { + var command = new Command(Name, Description); + command.SetAction(async (_, _) => + { + var options = services.GetRequiredService(); + var outputOptions = services.GetRequiredService(); + var toc = await services.GetServices().CollectTocEntriesAsync(); + var tree = await services.GetRequiredService().BuildTreeAsync(toc); + var localization = options.Localization; + + var features = new List { "search" }; + if (options.MapSitemap) + { + features.Add("sitemap"); + } + + if (options.LlmsTxt is not null) + { + features.Add("llms.txt"); + } + + var sourceCount = options.MarkdownSources.Count; + + output.WriteLine($"Pennington {PenningtonVersion.Value}"); + output.WriteLine(); + output.WriteLine($"Site: {(string.IsNullOrWhiteSpace(options.SiteTitle) ? "(untitled)" : options.SiteTitle)}"); + if (!string.IsNullOrWhiteSpace(options.SiteDescription)) + { + output.WriteLine($"Description: {options.SiteDescription}"); + } + + output.WriteLine($"Base URL: {options.CanonicalBaseUrl ?? "(not set)"}"); + output.WriteLine($"Content: {options.ContentRootPath} ({sourceCount} markdown source{(sourceCount == 1 ? "" : "s")})"); + output.WriteLine($"Output: {outputOptions.OutputDirectory} (base {outputOptions.BaseUrl})"); + output.WriteLine($"Pages: {toc.Count}"); + output.WriteLine($"Sections: {(tree.Count == 0 ? "(none)" : string.Join(", ", tree.Select(t => t.Title)))}"); + output.WriteLine($"Locales: {FormatLocales(localization)}"); + output.WriteLine($"Features: {string.Join(", ", features)}"); + return 0; + }); + return command; + } + + private static string FormatLocales(LocalizationOptions localization) + { + if (!localization.IsMultiLocale) + { + return $"{localization.DefaultLocale} (single-locale)"; + } + + return string.Join(", ", localization.Locales.Keys.Select(code => + string.Equals(code, localization.DefaultLocale, StringComparison.OrdinalIgnoreCase) + ? $"{code} (default)" + : code)); + } +} diff --git a/src/Pennington/Cli/Diag/DiagLlmsCommand.cs b/src/Pennington/Cli/Diag/DiagLlmsCommand.cs new file mode 100644 index 00000000..c8aeead1 --- /dev/null +++ b/src/Pennington/Cli/Diag/DiagLlmsCommand.cs @@ -0,0 +1,35 @@ +namespace Pennington.Cli.Diag; + +using System.CommandLine; +using Infrastructure; +using LlmsTxt; +using Microsoft.Extensions.DependencyInjection; + +/// diag llms — preview the generated llms.txt index. +internal sealed class DiagLlmsCommand : IDiagCommand +{ + /// + public string Name => "llms"; + + /// + public string Description => "Preview the generated llms.txt index for the site."; + + /// + public Command Build(IServiceProvider services, TextWriter output) + { + var command = new Command(Name, Description); + command.SetAction(async (_, _) => + { + if (services.GetRequiredService().LlmsTxt is null) + { + output.WriteLine("llms.txt is not enabled for this site (call options.AddLlmsTxt(...) to enable it)."); + return 0; + } + + var content = await services.GetRequiredService().GetLlmsTxtAsync(); + output.WriteLine(content); + return 0; + }); + return command; + } +} diff --git a/src/Pennington/Cli/Diag/DiagRoutesCommand.cs b/src/Pennington/Cli/Diag/DiagRoutesCommand.cs new file mode 100644 index 00000000..b7049617 --- /dev/null +++ b/src/Pennington/Cli/Diag/DiagRoutesCommand.cs @@ -0,0 +1,95 @@ +namespace Pennington.Cli.Diag; + +using System.CommandLine; +using Content; +using Microsoft.Extensions.DependencyInjection; +using Pipeline; + +/// diag routes — a flat list of every URL the site emits, with its kind and source file. +internal sealed class DiagRoutesCommand : IDiagCommand +{ + /// + public string Name => "routes"; + + /// + public string Description => "List every emitted route: URL, kind (markdown/razor/redirect/...), and source file."; + + /// + public Command Build(IServiceProvider services, TextWriter output) + { + var kindOption = new Option("--kind") + { + Description = "Filter by kind: markdown, razor, redirect, programmatic, endpoint, or llms-only.", + }; + var localeOption = new Option("--locale") + { + Description = "Filter by locale code.", + }; + + var command = new Command(Name, Description); + command.Options.Add(kindOption); + command.Options.Add(localeOption); + command.SetAction(async (parseResult, cancellationToken) => + { + var kindFilter = parseResult.GetValue(kindOption); + var localeFilter = parseResult.GetValue(localeOption); + + var rows = new List(); + await foreach (var item in services.GetServices().DiscoverAllAsync(cancellationToken)) + { + var kind = KindOf(item.Source); + if (!string.IsNullOrEmpty(kindFilter) && !string.Equals(kind, kindFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.IsNullOrEmpty(localeFilter) && !string.Equals(item.Route.Locale, localeFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var redirect = (item.Source.Value as RedirectSource)?.TargetUrl.Value; + rows.Add(new Row(item.Route.CanonicalPath.Value, kind, item.Route.SourceFile?.Value, redirect)); + } + + rows.Sort((a, b) => string.CompareOrdinal(a.Url, b.Url)); + + if (rows.Count == 0) + { + output.WriteLine("No routes found."); + return 0; + } + + var urlWidth = Math.Max(3, rows.Max(r => r.Url.Length)); + var kindWidth = Math.Max(4, rows.Max(r => r.Kind.Length)); + output.WriteLine($"{"URL".PadRight(urlWidth)} {"KIND".PadRight(kindWidth)} SOURCE"); + foreach (var row in rows) + { + var source = row.Redirect is not null + ? $"-> {row.Redirect}" + : string.IsNullOrEmpty(row.SourceFile) ? "(generated)" : row.SourceFile; + output.WriteLine($"{row.Url.PadRight(urlWidth)} {row.Kind.PadRight(kindWidth)} {source}"); + } + + output.WriteLine(); + var byKind = rows.GroupBy(r => r.Kind).OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(g => $"{g.Count()} {g.Key}"); + output.WriteLine($"{rows.Count} route{(rows.Count == 1 ? "" : "s")} ({string.Join(", ", byKind)})"); + return 0; + }); + return command; + } + + private static string KindOf(ContentSource source) => source.Value switch + { + MarkdownFileSource => "markdown", + RazorPageSource => "razor", + RedirectSource => "redirect", + ProgrammaticSource => "programmatic", + EndpointSource => "endpoint", + LlmsOnlySource => "llms-only", + _ => "unknown", + }; + + private sealed record Row(string Url, string Kind, string? SourceFile, string? Redirect); +} diff --git a/src/Pennington/Cli/Diag/DiagTocCommand.cs b/src/Pennington/Cli/Diag/DiagTocCommand.cs new file mode 100644 index 00000000..f7706f05 --- /dev/null +++ b/src/Pennington/Cli/Diag/DiagTocCommand.cs @@ -0,0 +1,148 @@ +namespace Pennington.Cli.Diag; + +using System.CommandLine; +using Content; +using Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Navigation; + +/// diag toc — an ASCII tree of the table of contents, grouped by top-level section/area. +internal sealed class DiagTocCommand : IDiagCommand +{ + /// + public string Name => "toc"; + + /// + public string Description => "Print the table of contents as a tree, grouped by top-level section/area."; + + /// + public Command Build(IServiceProvider services, TextWriter output) + { + var localeOption = new Option("--locale") + { + Description = "Locale to render (default: the site default locale).", + }; + var areaOption = new Option("--area") + { + Description = "Limit to one top-level section/area by slug (its first URL segment).", + }; + var depthOption = new Option("--depth") + { + Description = "Maximum tree depth to print (default: unlimited).", + }; + + var command = new Command(Name, Description); + command.Options.Add(localeOption); + command.Options.Add(areaOption); + command.Options.Add(depthOption); + command.SetAction(async (parseResult, _) => + { + var areaSlug = parseResult.GetValue(areaOption); + var maxDepth = parseResult.GetValue(depthOption) ?? int.MaxValue; + + var options = services.GetRequiredService(); + var localization = options.Localization; + var toc = await services.GetServices().CollectTocEntriesAsync(); + var effectiveLocale = parseResult.GetValue(localeOption) + ?? (localization.IsMultiLocale ? localization.DefaultLocale : null); + + var tree = await services.GetRequiredService() + .BuildTreeAsync(toc, currentRoute: null, locale: effectiveLocale); + + // Recover per-page flags the nav tree drops by keying TOC items on canonical path. + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in toc) + { + byPath[item.Route.CanonicalPath.Value] = item; + } + + var localeForSlug = effectiveLocale ?? localization.DefaultLocale; + IReadOnlyList sections = tree; + if (!string.IsNullOrEmpty(areaSlug)) + { + var match = tree.FirstOrDefault(n => + string.Equals(SlugOf(n, localization, localeForSlug), areaSlug, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + var available = string.Join(", ", tree.Select(n => SlugOf(n, localization, localeForSlug))); + output.WriteLine($"No area '{areaSlug}' found. Available: {available}"); + return 2; + } + + sections = [match]; + } + + var title = string.IsNullOrWhiteSpace(options.SiteTitle) ? "(untitled)" : options.SiteTitle; + output.WriteLine($"{title} ({toc.Count} pages, {sections.Count} section{(sections.Count == 1 ? "" : "s")})"); + output.WriteLine(); + + foreach (var section in sections) + { + output.WriteLine($"{section.Title} ({RouteLabel(section)})"); + if (section.Children.Count > 0 && maxDepth > 1) + { + AsciiTreeWriter.Write( + output, + section.Children, + node => Label(node, byPath), + node => node.Children, + maxDepth - 1); + } + + output.WriteLine(); + } + + return 0; + }); + return command; + } + + private static string SlugOf(NavigationTreeItem node, LocalizationOptions localization, string locale) + { + var stripped = localization.StripLocalePrefix(node.Route.CanonicalPath.Value, locale).Trim('/'); + if (!string.IsNullOrEmpty(stripped)) + { + return stripped.Split('/')[0]; + } + + return node.Title.Replace(' ', '-').ToLowerInvariant(); + } + + private static string RouteLabel(NavigationTreeItem node) + { + var path = node.Route.CanonicalPath.Value; + return string.IsNullOrEmpty(path) ? "section" : path; + } + + private static string Label(NavigationTreeItem node, IReadOnlyDictionary byPath) + { + var path = node.Route.CanonicalPath.Value; + var flags = ""; + if (byPath.TryGetValue(path, out var item)) + { + var parts = new List(); + if (item.SearchOnly) + { + parts.Add("search-only"); + } + + if (item.ExcludeFromSearch) + { + parts.Add("no-search"); + } + + if (item.ExcludeFromLlms) + { + parts.Add("no-llms"); + } + + if (parts.Count > 0) + { + flags = " [" + string.Join("] [", parts) + "]"; + } + } + + var route = string.IsNullOrEmpty(path) ? "" : $" {path}"; + return $"{node.Title}{route} #{node.Order}{flags}"; + } +} diff --git a/src/Pennington/Cli/Diag/DiagTranslationCommand.cs b/src/Pennington/Cli/Diag/DiagTranslationCommand.cs new file mode 100644 index 00000000..abaf0361 --- /dev/null +++ b/src/Pennington/Cli/Diag/DiagTranslationCommand.cs @@ -0,0 +1,148 @@ +namespace Pennington.Cli.Diag; + +using System.CommandLine; +using Content; +using Generation; +using Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +/// diag translation (alias i18n) — per-locale page coverage, fallback pages, and untranslated UI strings. +internal sealed class DiagTranslationCommand : IDiagCommand +{ + /// + public string Name => "translation"; + + /// + public string Description => "Diagnose localization: per-locale coverage, fallback pages, and untranslated UI strings."; + + /// + public Command Build(IServiceProvider services, TextWriter output) + { + var localeOption = new Option("--locale") + { + Description = "Limit the detailed breakdown to one locale.", + }; + + var command = new Command(Name, Description); + command.Aliases.Add("i18n"); + command.Options.Add(localeOption); + command.SetAction(async (parseResult, _) => + { + var localeFilter = parseResult.GetValue(localeOption); + var options = services.GetRequiredService(); + var localization = options.Localization; + var defaultLocale = localization.DefaultLocale; + + if (!localization.IsMultiLocale) + { + output.WriteLine($"Site is single-locale (default: {defaultLocale}). Nothing to diagnose."); + return 0; + } + + var toc = await services.GetServices().CollectTocEntriesAsync(); + + // Real (non-fallback) locale-stripped paths per locale; default-locale set is the source of truth. + var realByLocale = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var defaultKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in toc) + { + var route = item.Route; + if (route.IsFallback) + { + continue; + } + + var locale = item.Locale ?? (string.IsNullOrEmpty(route.Locale) ? defaultLocale : route.Locale); + var key = localization.StripLocalePrefix(route.CanonicalPath.Value, locale); + if (!realByLocale.TryGetValue(locale, out var set)) + { + set = new HashSet(StringComparer.OrdinalIgnoreCase); + realByLocale[locale] = set; + } + + set.Add(key); + if (string.Equals(locale, defaultLocale, StringComparison.OrdinalIgnoreCase)) + { + defaultKeys.Add(key); + } + } + + var translations = options.Translations; + var defaultUiKeys = translations.GetAll(defaultLocale).Keys; + + output.WriteLine($"Translation — default: {defaultLocale}, {localization.Locales.Count} locales"); + output.WriteLine(); + output.WriteLine($" {"LOCALE",-8}{"PAGES",-7}{"TRANSLATED",-14}{"FALLBACK",-10}{"UI GAPS"}"); + foreach (var (code, _) in localization.Locales) + { + var isDefault = string.Equals(code, defaultLocale, StringComparison.OrdinalIgnoreCase); + var real = realByLocale.GetValueOrDefault(code) ?? []; + var translated = isDefault ? defaultKeys.Count : defaultKeys.Count(real.Contains); + var fallback = defaultKeys.Count - translated; + var percent = defaultKeys.Count == 0 ? 100 : (int)Math.Round(translated * 100.0 / defaultKeys.Count); + var uiGaps = isDefault + ? "—" + : defaultUiKeys.Except(translations.GetAll(code).Keys, StringComparer.OrdinalIgnoreCase).Count().ToString(); + output.WriteLine($" {code,-8}{defaultKeys.Count,-7}{$"{translated} ({percent}%)",-14}{fallback,-10}{uiGaps}"); + } + + output.WriteLine(); + + foreach (var (code, _) in localization.Locales) + { + if (string.Equals(code, defaultLocale, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.IsNullOrEmpty(localeFilter) && !string.Equals(code, localeFilter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var real = realByLocale.GetValueOrDefault(code) ?? []; + var fallbackPages = defaultKeys.Where(k => !real.Contains(k)).OrderBy(k => k, StringComparer.Ordinal).ToList(); + if (fallbackPages.Count > 0) + { + output.WriteLine($"Fallback pages ({code}):"); + foreach (var page in fallbackPages) + { + output.WriteLine($" {page}"); + } + + output.WriteLine(); + } + + var uiGapKeys = defaultUiKeys + .Except(translations.GetAll(code).Keys, StringComparer.OrdinalIgnoreCase) + .OrderBy(k => k, StringComparer.Ordinal) + .ToList(); + if (uiGapKeys.Count > 0) + { + output.WriteLine($"Missing UI strings ({code}): {string.Join(", ", uiGapKeys)}"); + output.WriteLine(); + } + } + + // Optional git-based audit (Pennington.TranslationAudit). Diagnostics land in the audit + // cache keyed by the auditor's "translation.audit/" source prefix; surface them if present. + await services.GetRequiredService().WaitForInitialPassAsync(); + var auditDiagnostics = services.GetRequiredService().Diagnostics + .Where(d => d.SourceFile?.StartsWith("translation.audit/", StringComparison.Ordinal) == true) + .ToList(); + if (auditDiagnostics.Count > 0) + { + output.WriteLine("Translation audit (git):"); + foreach (var diagnostic in auditDiagnostics) + { + output.WriteLine($" {diagnostic.Message}"); + } + + output.WriteLine(); + } + + return 0; + }); + return command; + } +} diff --git a/src/Pennington/Cli/Diag/DiagWarningsCommand.cs b/src/Pennington/Cli/Diag/DiagWarningsCommand.cs new file mode 100644 index 00000000..2a1e1024 --- /dev/null +++ b/src/Pennington/Cli/Diag/DiagWarningsCommand.cs @@ -0,0 +1,169 @@ +namespace Pennington.Cli.Diag; + +using System.Collections.Immutable; +using System.CommandLine; +using Diagnostics; +using Generation; +using Microsoft.Extensions.DependencyInjection; + +/// diag warnings — the site's current diagnostics, grouped by severity. +internal sealed class DiagWarningsCommand : IDiagCommand +{ + /// + public string Name => "warnings"; + + /// + public string Description => "List current diagnostics (broken xrefs, translation, structure). Use --full to also crawl for broken links."; + + /// + public Command Build(IServiceProvider services, TextWriter output) + { + var fullOption = new Option("--full") + { + Description = "Run a full in-memory crawl that also reports broken links and render warnings. Slower.", + }; + var severityOption = new Option("--severity") + { + Description = "Minimum severity to show: error, warning, or info (this level and above).", + }; + + var command = new Command(Name, Description); + command.Options.Add(fullOption); + command.Options.Add(severityOption); + command.SetAction(async (parseResult, _) => + { + var full = parseResult.GetValue(fullOption); + var threshold = ParseThreshold(parseResult.GetValue(severityOption)); + + ImmutableList diagnostics; + if (full) + { + var report = await services.GetRequiredService().GenerateAsync(writeToDisk: false); + diagnostics = report.Diagnostics; + } + else + { + await services.GetRequiredService().WaitForInitialPassAsync(); + diagnostics = services.GetRequiredService().Diagnostics; + } + + // The exit code reflects whether the site has any errors at all, independent of the + // display filter, so `diag warnings` is a meaningful CI/agent gate. + var hasError = diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error); + WriteReport(output, diagnostics.Where(d => Rank(d.Severity) >= threshold).ToList()); + return hasError ? 1 : 0; + }); + return command; + } + + private static int Rank(DiagnosticSeverity severity) => severity switch + { + DiagnosticSeverity.Error => 2, + DiagnosticSeverity.Warning => 1, + _ => 0, + }; + + private static int ParseThreshold(string? severity) => severity?.ToLowerInvariant() switch + { + "error" => 2, + "warning" => 1, + _ => 0, + }; + + private static void WriteReport(TextWriter output, IReadOnlyList diagnostics) + { + var errors = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + var warnings = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Warning).ToList(); + var infos = diagnostics.Where(d => d.Severity == DiagnosticSeverity.Info).ToList(); + + output.WriteLine($"Diagnostics — {errors.Count} error{P(errors.Count)}, {warnings.Count} warning{P(warnings.Count)}, {infos.Count} info"); + output.WriteLine(); + + if (errors.Count > 0) + { + output.WriteLine("ERRORS"); + foreach (var diagnostic in errors) + { + WriteDetail(output, diagnostic); + } + + output.WriteLine(); + } + + if (warnings.Count > 0) + { + output.WriteLine("WARNINGS"); + foreach (var diagnostic in warnings) + { + WriteBrief(output, diagnostic); + } + + output.WriteLine(); + } + + if (infos.Count > 0) + { + output.WriteLine("INFO"); + foreach (var diagnostic in infos) + { + WriteBrief(output, diagnostic); + } + + output.WriteLine(); + } + + if (diagnostics.Count == 0) + { + output.WriteLine("No diagnostics."); + } + } + + private static void WriteDetail(TextWriter output, BuildDiagnostic diagnostic) + { + if (diagnostic.Route is { } route) + { + output.WriteLine($" {route.CanonicalPath}"); + output.WriteLine($" {diagnostic.Message}"); + if (route.SourceFile is { } routeSource) + { + output.WriteLine($" Source: {routeSource}"); + } + else if (diagnostic.SourceFile is { } diagSource) + { + output.WriteLine($" {diagSource}"); + } + } + else if (diagnostic.SourceFile is { } sourceFile) + { + output.WriteLine($" {sourceFile}"); + output.WriteLine($" {diagnostic.Message}"); + } + else + { + output.WriteLine($" {diagnostic.Message}"); + } + + if (diagnostic.Exception is { } ex) + { + output.WriteLine($" Exception: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static void WriteBrief(TextWriter output, BuildDiagnostic diagnostic) + { + if (diagnostic.Route is { } route) + { + output.WriteLine($" {route.CanonicalPath}: {diagnostic.Message}"); + } + else if (diagnostic.SourceFile is { } sourceFile) + { + output.WriteLine($" {sourceFile}: {diagnostic.Message}"); + } + else + { + output.WriteLine($" {diagnostic.Message}"); + } + } + + private static string P(int count) => count == 1 ? "" : "s"; +} diff --git a/src/Pennington/Cli/IDiagCommand.cs b/src/Pennington/Cli/IDiagCommand.cs new file mode 100644 index 00000000..eb193112 --- /dev/null +++ b/src/Pennington/Cli/IDiagCommand.cs @@ -0,0 +1,25 @@ +namespace Pennington.Cli; + +using System.CommandLine; + +/// +/// A diag subcommand. Implementations register in DI as ; +/// discovers them and adds one System.CommandLine +/// per implementation under the diag group. Each command inspects +/// the started app's services and writes a human-readable text report. +/// +internal interface IDiagCommand +{ + /// Subcommand verb shown under diag (e.g. toc, warnings). + string Name { get; } + + /// One-line description shown in diag --help. + string Description { get; } + + /// + /// Builds the System.CommandLine for this subcommand, wiring its options + /// and an action that inspects (a fully started host's service + /// provider) and writes to . The action returns the process exit code. + /// + Command Build(IServiceProvider services, TextWriter output); +} diff --git a/src/Pennington/Cli/PenningtonCli.cs b/src/Pennington/Cli/PenningtonCli.cs new file mode 100644 index 00000000..32ac4cbd --- /dev/null +++ b/src/Pennington/Cli/PenningtonCli.cs @@ -0,0 +1,108 @@ +namespace Pennington.Cli; + +using System.CommandLine; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Single source of truth for run-mode detection and owner of the build / diag +/// command definitions. classifies the verb from the process command line +/// () so DI-time wiring and run-time dispatch agree — +/// host Program.cs is unaffected. +/// +internal sealed class PenningtonCli +{ + /// Shared instance, classified from the current process command line. + public static PenningtonCli Current { get; } = new(SliceProcessArgs()); + + /// Classifies the run mode from the supplied param-style args (verb at index 0). + public PenningtonCli(string[] args) + { + Mode = ClassifyMode(args); + IsHelpOrVersion = args.Any(IsHelpOrVersionToken); + } + + /// Run mode the CLI verb maps to; when no known verb is present. + public PenningtonRunMode Mode { get; } + + /// + /// True when the command line is a help or version request. Such invocations print and exit + /// without running the host, so they keep stdout clean like a run. + /// + public bool IsHelpOrVersion { get; } + + /// True for or — headless, in-process, no socket bind. + public bool IsHeadlessOneShot => Mode is PenningtonRunMode.Build or PenningtonRunMode.Diag; + + /// True only for — strict front-matter keys and disk writes. + public bool WritesOutput => Mode is PenningtonRunMode.Build; + + private static PenningtonRunMode ClassifyMode(string[] args) + { + if (args.Length == 0) + { + return PenningtonRunMode.Serve; + } + + if (args[0].Equals("build", StringComparison.OrdinalIgnoreCase)) + { + return PenningtonRunMode.Build; + } + + if (args[0].Equals("diag", StringComparison.OrdinalIgnoreCase)) + { + return PenningtonRunMode.Diag; + } + + return PenningtonRunMode.Serve; + } + + private static string[] SliceProcessArgs() + { + // GetCommandLineArgs()[0] is the executable; user args start at [1] — the same + // slice OutputOptions.FromArgs and the legacy build-mode detector consume. + var args = Environment.GetCommandLineArgs(); + return args.Length > 1 ? args[1..] : []; + } + + /// + /// Builds the build verb. Its options exist to document the build CLI in --help; + /// the effective values come from at DI time, so + /// the action wired by the caller ignores the parsed option/positional values. Unmatched tokens + /// are tolerated to preserve the historical positional forms (build /sub dist). + /// + public static Command CreateBuildCommand() + { + var baseUrl = new Option("--base-url") + { + Description = "Base URL the site is deployed under, e.g. /docs (also accepted positionally). Default: /", + }; + var output = new Option("--output") + { + Description = "Directory to write generated output to (also accepted positionally). Default: output", + }; + + var build = new Command("build", "Generate the static site to the output directory, then exit."); + build.TreatUnmatchedTokensAsErrors = false; + build.Options.Add(baseUrl); + build.Options.Add(output); + return build; + } + + /// True when is a help or version request token. + public static bool IsHelpOrVersionToken(string arg) => arg is "--help" or "-h" or "-?" or "--version"; + + /// + /// Builds the diag command group from every registered , each + /// running against and writing to . + /// + public static Command BuildDiagGroup(IServiceProvider services, TextWriter output) + { + var diag = new Command("diag", "Inspect the site (read-only). Built for humans and AI assistants."); + foreach (var command in services.GetServices().OrderBy(c => c.Name, StringComparer.Ordinal)) + { + diag.Subcommands.Add(command.Build(services, output)); + } + + return diag; + } +} diff --git a/src/Pennington/Cli/PenningtonRunMode.cs b/src/Pennington/Cli/PenningtonRunMode.cs new file mode 100644 index 00000000..92aad356 --- /dev/null +++ b/src/Pennington/Cli/PenningtonRunMode.cs @@ -0,0 +1,19 @@ +namespace Pennington.Cli; + +/// +/// The mode the Pennington host runs in, derived from the CLI verb. Drives whether the host +/// serves live, performs a one-shot static build, or runs a one-shot diagnostic command. +/// and are both headless one-shot modes; only +/// writes output and forces strict front-matter keys. +/// +internal enum PenningtonRunMode +{ + /// Dev server (default — no verb). Live reload, dev overlays, short shutdown timeout. + Serve, + + /// One-shot static build (build verb). Crawl in-process, write to disk, then exit. + Build, + + /// One-shot diagnostic command (diag <sub> verb). Inspect in-process, emit a report, then exit. + Diag, +} diff --git a/src/Pennington/Cli/PenningtonVersion.cs b/src/Pennington/Cli/PenningtonVersion.cs new file mode 100644 index 00000000..9d54b816 --- /dev/null +++ b/src/Pennington/Cli/PenningtonVersion.cs @@ -0,0 +1,18 @@ +namespace Pennington.Cli; + +using System.Reflection; + +/// Resolves the Pennington package version from assembly metadata for diagnostic output. +internal static class PenningtonVersion +{ + /// Informational version with MinVer build metadata trimmed (matches the published NuGet version). + public static string Value { get; } = Resolve(); + + private static string Resolve() + { + var attr = typeof(PenningtonVersion).Assembly.GetCustomAttribute(); + var raw = attr?.InformationalVersion ?? "unknown"; + var plus = raw.IndexOf('+'); + return plus >= 0 ? raw[..plus] : raw; + } +} diff --git a/src/Pennington/Generation/AuditRunner.cs b/src/Pennington/Generation/AuditRunner.cs index 64fb89a2..68d6b4d3 100644 --- a/src/Pennington/Generation/AuditRunner.cs +++ b/src/Pennington/Generation/AuditRunner.cs @@ -31,7 +31,7 @@ public AuditRunner( IFileWatcher fileWatcher, LocalizationOptions localization, ILogger logger) - : this(services, cache, fileWatcher, localization, logger, PenningtonBuildMode.IsBuildMode()) + : this(services, cache, fileWatcher, localization, logger, PenningtonBuildMode.WritesOutput) { } @@ -66,6 +66,13 @@ public Task StartAsync(CancellationToken cancellationToken) /// public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// + /// Awaits the initial audit pass kicked off by so callers (e.g. the + /// diag CLI) read a fully-populated . Completes immediately when + /// no pass has been started. + /// + internal Task WaitForInitialPassAsync() => _activeRun ?? Task.CompletedTask; + private void RunInBackground() { // Coalesce: if a run is already in flight, let it finish — the file watcher diff --git a/src/Pennington/Infrastructure/AuditDiagnosticProcessor.cs b/src/Pennington/Infrastructure/AuditDiagnosticProcessor.cs index f48cd57d..b7e5e894 100644 --- a/src/Pennington/Infrastructure/AuditDiagnosticProcessor.cs +++ b/src/Pennington/Infrastructure/AuditDiagnosticProcessor.cs @@ -13,7 +13,7 @@ namespace Pennington.Infrastructure; /// public sealed class AuditDiagnosticProcessor : IResponseProcessor { - private readonly bool _isDevMode = !PenningtonBuildMode.IsBuildMode(); + private readonly bool _isDevMode = !PenningtonBuildMode.IsHeadlessOneShot; /// Order picked so this runs before (Order=30) reads the context. public int Order => 25; diff --git a/src/Pennington/Infrastructure/DiagnosticOverlayProcessor.cs b/src/Pennington/Infrastructure/DiagnosticOverlayProcessor.cs index e1061a49..0856f43e 100644 --- a/src/Pennington/Infrastructure/DiagnosticOverlayProcessor.cs +++ b/src/Pennington/Infrastructure/DiagnosticOverlayProcessor.cs @@ -13,7 +13,7 @@ namespace Pennington.Infrastructure; /// public sealed class DiagnosticOverlayProcessor : IResponseProcessor { - private readonly bool _isDevMode = !PenningtonBuildMode.IsBuildMode(); + private readonly bool _isDevMode = !PenningtonBuildMode.IsHeadlessOneShot; // Runs after the HTML rewriting pipeline (10). /// diff --git a/src/Pennington/Infrastructure/LiveReloadScriptProcessor.cs b/src/Pennington/Infrastructure/LiveReloadScriptProcessor.cs index 86a4c089..fce3e846 100644 --- a/src/Pennington/Infrastructure/LiveReloadScriptProcessor.cs +++ b/src/Pennington/Infrastructure/LiveReloadScriptProcessor.cs @@ -8,7 +8,7 @@ namespace Pennington.Infrastructure; /// public sealed class LiveReloadScriptProcessor : IResponseProcessor { - private readonly bool _isDevMode = !PenningtonBuildMode.IsBuildMode(); + private readonly bool _isDevMode = !PenningtonBuildMode.IsHeadlessOneShot; // Per-process fingerprint. Lets the SPA engine in a still-open browser tab // from a previous dev session detect that it's now talking to a different diff --git a/src/Pennington/Infrastructure/LiveReloadServer.cs b/src/Pennington/Infrastructure/LiveReloadServer.cs index bb4e9213..18e7852a 100644 --- a/src/Pennington/Infrastructure/LiveReloadServer.cs +++ b/src/Pennington/Infrastructure/LiveReloadServer.cs @@ -139,7 +139,7 @@ public static class LiveReloadExtensions /// public static WebApplication UsePenningtonLiveReload(this WebApplication app) { - if (PenningtonBuildMode.IsBuildMode()) + if (PenningtonBuildMode.IsHeadlessOneShot) { return app; } diff --git a/src/Pennington/Infrastructure/PageLinkAuditProcessor.cs b/src/Pennington/Infrastructure/PageLinkAuditProcessor.cs index b84f8e41..b9b47239 100644 --- a/src/Pennington/Infrastructure/PageLinkAuditProcessor.cs +++ b/src/Pennington/Infrastructure/PageLinkAuditProcessor.cs @@ -15,7 +15,7 @@ namespace Pennington.Infrastructure; /// public sealed class PageLinkAuditProcessor : IResponseProcessor { - private readonly bool _isDevMode = !PenningtonBuildMode.IsBuildMode(); + private readonly bool _isDevMode = !PenningtonBuildMode.IsHeadlessOneShot; /// Order picked so this runs before (Order=30) reads the context. public int Order => 24; diff --git a/src/Pennington/Infrastructure/PenningtonBuildMode.cs b/src/Pennington/Infrastructure/PenningtonBuildMode.cs index 3eb53ffe..3e40b1d2 100644 --- a/src/Pennington/Infrastructure/PenningtonBuildMode.cs +++ b/src/Pennington/Infrastructure/PenningtonBuildMode.cs @@ -1,32 +1,36 @@ namespace Pennington.Infrastructure; +using Cli; + /// -/// Detects whether the host is running in static-build mode (CLI verb "build") -/// versus dev-serve mode. Centralized so every call site agrees on the array -/// shape — param-style arrays from Main have the verb at index 0; -/// has the executable at index 0 -/// and the verb at index 1. +/// Legacy build-vs-serve detector, retained for backward compatibility. New code consults +/// , which also distinguishes the headless +/// mode this boolean cannot represent. The convenience +/// members below give in-assembly call sites the three-state distinction without each one +/// taking a dependency on directly. /// public static class PenningtonBuildMode { /// - /// Returns true when (param-style: verb at index 0) - /// starts with the "build" verb. Use when a caller already has the args - /// array forwarded from Main. + /// Returns true when (param-style: verb at index 0) starts with the + /// build verb. Used where a caller already holds a forwarded args array (e.g. + /// ). /// public static bool IsBuildMode(string[] args) => args.Length > 0 && args[0].Equals("build", StringComparison.OrdinalIgnoreCase); + /// Returns true when the current process was launched with the build verb. + public static bool IsBuildMode() => PenningtonCli.Current.WritesOutput; + /// - /// Returns true when the current process was launched with the "build" - /// verb. Reads , slices off the - /// executable at index 0, and delegates to . - /// Use from middleware, processors, and other components that don't receive - /// Main's args directly. + /// True when the current process runs a headless one-shot command (build or + /// diag) — neither serves a dev session, injects overlays/live-reload, nor binds a socket. /// - public static bool IsBuildMode() - { - var args = Environment.GetCommandLineArgs(); - return args.Length > 1 && IsBuildMode(args[1..]); - } -} \ No newline at end of file + internal static bool IsHeadlessOneShot => PenningtonCli.Current.IsHeadlessOneShot; + + /// True only when the current process performs a static build that writes output to disk. + internal static bool WritesOutput => PenningtonCli.Current.WritesOutput; + + /// True when the current process was launched with a help or version flag. + internal static bool IsHelpOrVersion => PenningtonCli.Current.IsHelpOrVersion; +} diff --git a/src/Pennington/Infrastructure/PenningtonExtensions.cs b/src/Pennington/Infrastructure/PenningtonExtensions.cs index b476c5ae..61d76c6f 100644 --- a/src/Pennington/Infrastructure/PenningtonExtensions.cs +++ b/src/Pennington/Infrastructure/PenningtonExtensions.cs @@ -1,7 +1,10 @@ namespace Pennington.Infrastructure; +using System.CommandLine; using System.IO.Abstractions; using System.Reflection; +using Cli; +using Cli.Diag; using Content; using Feeds; using FrontMatter; @@ -70,25 +73,35 @@ public static IServiceCollection AddPennington(this IServiceCollection services, var outputOptions = OutputOptions.FromArgs(args.Length > 1 ? args[1..] : []); services.AddSingleton(outputOptions); - // Build mode: replace Kestrel with TestServer so the crawler dispatches - // in-memory through the same middleware pipeline. No socket bind, no - // dev-cert prompt, no random-port races in CI. Last-registered IServer - // wins, so this overrides the Kestrel registration that - // WebApplication.CreateBuilder() puts in place. - if (PenningtonBuildMode.IsBuildMode()) + // Build and diag run in-process: swap Kestrel for TestServer so the crawler dispatches + // through the same middleware with no socket bind, dev-cert prompt, or random-port race. + // Last-registered IServer wins, overriding the Kestrel registration CreateBuilder() adds. + if (PenningtonBuildMode.IsHeadlessOneShot) { services.AddSingleton(); + } - // Mute the host's "Application started / Hosting environment / Content root" - // chatter — the build is in-process and the user wants Pennington's own - // progress messages, not lifetime breadcrumbs. Pennington.* is bumped to - // Information so the phase logs surface even when the host's appsettings - // sets a Default of Warning. The custom formatter strips the - // "info: Category[0]" prefix so the build reads like a CLI tool. + // One-shot commands (build/diag) and help/version requests print to stdout and exit, so the + // host's lifetime chatter and dev-level Pennington logs are muted (the custom formatter also + // strips the "info: Category[0]" prefix so output reads like a CLI tool). Only a real build + // surfaces its own progress at Information; diag, --help, and --version keep stdout clean — + // the explicit Pennington filter overrides hosts whose appsettings elevate it (e.g. the docs + // site sets Pennington to Trace). + if (PenningtonBuildMode.IsHeadlessOneShot || PenningtonBuildMode.IsHelpOrVersion) + { services.AddLogging(b => { b.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); - b.AddFilter("Pennington", LogLevel.Information); + if (PenningtonBuildMode.WritesOutput && !PenningtonBuildMode.IsHelpOrVersion) + { + b.AddFilter("Pennington", LogLevel.Information); + } + else + { + b.SetMinimumLevel(LogLevel.Warning); + b.AddFilter("Pennington", LogLevel.Warning); + } + b.AddConsole(o => o.FormatterName = BuildConsoleFormatter.FormatterName); b.AddConsoleFormatter(); }); @@ -107,7 +120,7 @@ public static IServiceCollection AddPennington(this IServiceCollection services, // Build mode defaults StrictUnknownKeys to true so typo'd keys fail the build. // The user wins if they already flipped it on; flip-down for build is rare and // can still be done by registering a replacement options instance after AddPennington. - if (PenningtonBuildMode.IsBuildMode() && !options.FrontMatter.StrictUnknownKeys) + if (PenningtonBuildMode.WritesOutput && !options.FrontMatter.StrictUnknownKeys) { options.FrontMatter.StrictUnknownKeys = true; } @@ -310,11 +323,24 @@ object Resolve(IServiceProvider sp) // copies the same snapshot in OutputGenerationService. services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); - services.AddHostedService(); + // Registered as a resolvable singleton (not just AddHostedService) so the diag CLI + // can await its initial pass via WaitForInitialPassAsync() before reading the cache. + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); services.AddTransient(); services.AddTransient(); services.AddTransient(); + // Diagnostic CLI (`diag `): each command is discovered by PenningtonCli.BuildDiagGroup + // and run against the started host. Read-only inspection for humans and AI assistants. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // Live reload services.AddSingleton(); @@ -671,38 +697,58 @@ public static WebApplication UsePennington(this WebApplication app) return app; } - /// Run in dev mode or build static site. + /// + /// Runs the host: serves live (no verb), builds the static site (build), or runs a + /// diagnostic command (diag <sub>). Everything flows through one System.CommandLine + /// pipeline, so --help / --version work at the root and every subcommand. Build and + /// diag run one-shot against a started in-memory host that is disposed afterward; serve hands off + /// to . + /// public static async Task RunOrBuildAsync(this WebApplication app, string[] args) { StaticWebAssetsLoader.UseStaticWebAssets(app.Environment, app.Configuration); - if (PenningtonBuildMode.IsBuildMode(args)) + var root = new RootCommand("Pennington site host. Run with no command to start the dev server."); + root.TreatUnmatchedTokensAsErrors = false; + root.SetAction(async (_, _) => + { + await app.RunAsync(); + return 0; + }); + + var build = PenningtonCli.CreateBuildCommand(); + build.SetAction(async (_, _) => { - // Dispose the host when the one-shot build finishes so container singletons - // are torn down — notably SolutionWorkspaceService, whose Dispose() disposes - // the MSBuildWorkspace (terminating its BuildHost child and releasing mapped - // assembly handles) and deletes its per-run temp build folder. Without this, - // every build leaks a %TEMP%\Pennington_Build_* folder and leaves the - // workspace untorn-down; the resulting litter and orphaned handles are what - // intermittently starve the next build's metadata-reference resolution - // (producing sparse API reference pages). + var report = await app.Services.GetRequiredService().GenerateAsync(); + report.WriteTo(Console.Out); + return report.HasErrors ? 1 : 0; + }); + root.Subcommands.Add(build); + root.Subcommands.Add(PenningtonCli.BuildDiagGroup(app.Services, Console.Out)); + + var parseResult = root.Parse(args); + + // build/diag are one-shot commands that inspect a started, in-memory host; --help/--version + // just print, and serve starts the host itself via RunAsync — none of those need the wrapper. + if (!args.Any(PenningtonCli.IsHelpOrVersionToken) + && PenningtonCli.Current.Mode is PenningtonRunMode.Build or PenningtonRunMode.Diag) + { + // Dispose the host when the one-shot command finishes so container singletons are torn + // down — notably SolutionWorkspaceService, whose Dispose() terminates the MSBuildWorkspace + // (releasing its BuildHost child and mapped assembly handles) and deletes its per-run temp + // build folder. Without this, every build leaks a %TEMP%\Pennington_Build_* folder and + // leaves orphaned handles that intermittently starve the next build's metadata-reference + // resolution (producing sparse API reference pages). await using (app) { await app.StartAsync(); - var generator = app.Services.GetRequiredService(); - var report = await generator.GenerateAsync(); + Environment.ExitCode = await parseResult.InvokeAsync(); await app.StopAsync(); - - report.WriteTo(Console.Out); - if (report.HasErrors) - { - Environment.ExitCode = 1; - } } } else { - await app.RunAsync(); + Environment.ExitCode = await parseResult.InvokeAsync(); } } } \ No newline at end of file diff --git a/src/Pennington/Pennington.csproj b/src/Pennington/Pennington.csproj index 04d2705a..10292d24 100644 --- a/src/Pennington/Pennington.csproj +++ b/src/Pennington/Pennington.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Pennington.Tests/Cli/AsciiTreeWriterTests.cs b/tests/Pennington.Tests/Cli/AsciiTreeWriterTests.cs new file mode 100644 index 00000000..d6ab832c --- /dev/null +++ b/tests/Pennington.Tests/Cli/AsciiTreeWriterTests.cs @@ -0,0 +1,37 @@ +namespace Pennington.Tests.Cli; + +using Pennington.Cli; +using Shouldly; +using Xunit; + +public class AsciiTreeWriterTests +{ + private sealed record Node(string Name, IReadOnlyList Children); + + [Fact] + public void Renders_box_drawing_connectors() + { + var nodes = new List + { + new("a", [new Node("a1", []), new Node("a2", [])]), + new("b", []), + }; + + var writer = new StringWriter(); + AsciiTreeWriter.Write(writer, nodes, n => n.Name, n => n.Children); + + writer.ToString().ReplaceLineEndings("\n") + .ShouldBe("├─ a\n│ ├─ a1\n│ └─ a2\n└─ b\n"); + } + + [Fact] + public void Respects_max_depth() + { + var nodes = new List { new("a", [new Node("a1", [])]) }; + + var writer = new StringWriter(); + AsciiTreeWriter.Write(writer, nodes, n => n.Name, n => n.Children, maxDepth: 1); + + writer.ToString().ReplaceLineEndings("\n").ShouldBe("└─ a\n"); + } +} diff --git a/tests/Pennington.Tests/Cli/PenningtonCliTests.cs b/tests/Pennington.Tests/Cli/PenningtonCliTests.cs new file mode 100644 index 00000000..e3776aa5 --- /dev/null +++ b/tests/Pennington.Tests/Cli/PenningtonCliTests.cs @@ -0,0 +1,63 @@ +namespace Pennington.Tests.Cli; + +using Pennington.Cli; +using Shouldly; +using Xunit; + +public class PenningtonCliTests +{ + [Fact] + public void No_args_is_serve() + { + var cli = new PenningtonCli([]); + cli.Mode.ShouldBe(PenningtonRunMode.Serve); + cli.IsHeadlessOneShot.ShouldBeFalse(); + cli.WritesOutput.ShouldBeFalse(); + } + + [Fact] + public void Build_verb_is_build() + { + var cli = new PenningtonCli(["build"]); + cli.Mode.ShouldBe(PenningtonRunMode.Build); + cli.WritesOutput.ShouldBeTrue(); + cli.IsHeadlessOneShot.ShouldBeTrue(); + } + + [Fact] + public void Build_verb_is_case_insensitive() + { + new PenningtonCli(["BUILD"]).Mode.ShouldBe(PenningtonRunMode.Build); + new PenningtonCli(["Build"]).Mode.ShouldBe(PenningtonRunMode.Build); + } + + [Fact] + public void Build_with_options_and_positionals_is_build() + { + new PenningtonCli(["build", "/sub", "dist"]).Mode.ShouldBe(PenningtonRunMode.Build); + new PenningtonCli(["build", "--base-url=/x", "--output", "dist"]).Mode.ShouldBe(PenningtonRunMode.Build); + } + + [Fact] + public void Diag_verb_is_diag() + { + var cli = new PenningtonCli(["diag", "toc"]); + cli.Mode.ShouldBe(PenningtonRunMode.Diag); + cli.IsHeadlessOneShot.ShouldBeTrue(); + cli.WritesOutput.ShouldBeFalse(); + } + + [Fact] + public void Unknown_verb_is_serve() + { + new PenningtonCli(["frobnicate"]).Mode.ShouldBe(PenningtonRunMode.Serve); + } + + [Fact] + public void Host_and_test_args_are_serve() + { + // Stray host args (dotnet run --urls ...) and test-runner args must not be misread as a verb. + new PenningtonCli(["--urls", "http://localhost:5000"]).Mode.ShouldBe(PenningtonRunMode.Serve); + new PenningtonCli(["serve"]).Mode.ShouldBe(PenningtonRunMode.Serve); + } +} diff --git a/tests/Pennington.Tests/Tui/PenningtonTuiHostedServiceTests.cs b/tests/Pennington.Tests/Tui/PenningtonTuiHostedServiceTests.cs index 9c2b43b8..ebd83f9d 100644 --- a/tests/Pennington.Tests/Tui/PenningtonTuiHostedServiceTests.cs +++ b/tests/Pennington.Tests/Tui/PenningtonTuiHostedServiceTests.cs @@ -6,10 +6,10 @@ namespace Pennington.Tests.Tui; public class PenningtonTuiHostedServiceTests { - // Mirrors the gate in PenningtonExtensions.RunOrBuildAsync so the TUI and - // the build entry point agree on what "build mode" looks like. If this test - // drifts from the real check, the TUI will fire up during `dotnet run -- build` - // and fight Kestrel for the console. + // Mirrors the headless-one-shot gate in PenningtonExtensions.RunOrBuildAsync so the TUI + // and the CLI entry point agree on which verbs (build/diag) should suppress the TUI. If + // this test drifts from the real check, the TUI will fire up during `dotnet run -- build` + // or `dotnet run -- diag ...` and fight Kestrel/the report for the console. [Fact] public void IsBuildMode_true_for_build_arg() @@ -17,6 +17,13 @@ public void IsBuildMode_true_for_build_arg() PenningtonTuiHostedService.IsBuildMode(["Host.exe", "build"]).ShouldBeTrue(); } + [Fact] + public void IsBuildMode_true_for_diag_arg() + { + PenningtonTuiHostedService.IsBuildMode(["Host.exe", "diag"]).ShouldBeTrue(); + PenningtonTuiHostedService.IsBuildMode(["Host.exe", "diag", "toc"]).ShouldBeTrue(); + } + [Fact] public void IsBuildMode_case_insensitive() {