Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <site>` serves; `-- build [--base-url /x] [--output dir]` generates the static site; `-- diag <info|toc|routes|warnings|translation|frontmatter|llms>` 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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="Spectre.Console" Version="0.54.1-alpha.0.7" />
<PackageVersion Include="Spectre.Console.Cli" Version="1.0.0-alpha.0.7" />
<PackageVersion Include="System.CommandLine" Version="2.0.0" />
<PackageVersion Include="System.Reflection.MetadataLoadContext" Version="10.0.8" />
<PackageVersion Include="System.ServiceModel.Syndication" Version="10.0.8" />
<PackageVersion Include="Testably.Abstractions" Version="10.2.0" />
Expand Down
10 changes: 6 additions & 4 deletions src/Pennington.Tui/PenningtonTuiHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/Pennington/Cli/AsciiTreeWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
namespace Pennington.Cli;

/// <summary>Renders a hierarchy as an ASCII tree (<c>├─ └─ │</c>) to a <see cref="TextWriter"/>.</summary>
internal static class AsciiTreeWriter
{
private const string Tee = "├─ ";
private const string Ell = "└─ ";
private const string Bar = "│ ";
private const string Gap = " ";

/// <summary>
/// Writes <paramref name="nodes"/> as a tree. <paramref name="label"/> formats one node to a
/// single line; <paramref name="children"/> yields a node's children. Recurses until
/// <paramref name="maxDepth"/> (1-based; the top level is depth 1).
/// </summary>
public static void Write<T>(
TextWriter writer,
IReadOnlyList<T> nodes,
Func<T, string> label,
Func<T, IReadOnlyList<T>> children,
int maxDepth = int.MaxValue)
=> WriteLevel(writer, nodes, label, children, prefix: "", depth: 1, maxDepth);

private static void WriteLevel<T>(
TextWriter writer,
IReadOnlyList<T> nodes,
Func<T, string> label,
Func<T, IReadOnlyList<T>> 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);
}
}
}
}
109 changes: 109 additions & 0 deletions src/Pennington/Cli/Diag/DiagFrontMatterCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
namespace Pennington.Cli.Diag;

using System.CommandLine;
using System.Reflection;
using Content;
using FrontMatter;
using Infrastructure;
using Microsoft.Extensions.DependencyInjection;

/// <summary><c>diag frontmatter</c> — inventory of front-matter keys accepted by each content type in use.</summary>
internal sealed class DiagFrontMatterCommand : IDiagCommand
{
/// <inheritdoc/>
public string Name => "frontmatter";

/// <inheritdoc/>
public string Description => "Inventory the front-matter keys each content type accepts, and how many pages use each type.";

/// <inheritdoc/>
public Command Build(IServiceProvider services, TextWriter output)
{
var command = new Command(Name, Description);
command.SetAction(async (_, cancellationToken) =>
{
var countByType = new Dictionary<Type, int>();
await foreach (var item in services.GetServices<IContentService>().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<PenningtonOptions>().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<string> Capabilities(Type type)
{
var capabilities = new List<string>();
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;
}
}
76 changes: 76 additions & 0 deletions src/Pennington/Cli/Diag/DiagInfoCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
namespace Pennington.Cli.Diag;

using System.CommandLine;
using Content;
using Generation;
using Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Navigation;

/// <summary><c>diag info</c> — a high-level overview of the site: the shape of the app at a glance.</summary>
internal sealed class DiagInfoCommand : IDiagCommand
{
/// <inheritdoc/>
public string Name => "info";

/// <inheritdoc/>
public string Description => "Show a high-level overview: title, content roots, pages, sections, locales, and features.";

/// <inheritdoc/>
public Command Build(IServiceProvider services, TextWriter output)
{
var command = new Command(Name, Description);
command.SetAction(async (_, _) =>
{
var options = services.GetRequiredService<PenningtonOptions>();
var outputOptions = services.GetRequiredService<OutputOptions>();
var toc = await services.GetServices<IContentService>().CollectTocEntriesAsync();
var tree = await services.GetRequiredService<NavigationBuilder>().BuildTreeAsync(toc);
var localization = options.Localization;

var features = new List<string> { "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));
}
}
35 changes: 35 additions & 0 deletions src/Pennington/Cli/Diag/DiagLlmsCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Pennington.Cli.Diag;

using System.CommandLine;
using Infrastructure;
using LlmsTxt;
using Microsoft.Extensions.DependencyInjection;

/// <summary><c>diag llms</c> — preview the generated llms.txt index.</summary>
internal sealed class DiagLlmsCommand : IDiagCommand
{
/// <inheritdoc/>
public string Name => "llms";

/// <inheritdoc/>
public string Description => "Preview the generated llms.txt index for the site.";

/// <inheritdoc/>
public Command Build(IServiceProvider services, TextWriter output)
{
var command = new Command(Name, Description);
command.SetAction(async (_, _) =>
{
if (services.GetRequiredService<PenningtonOptions>().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<LlmsTxtService>().GetLlmsTxtAsync();
output.WriteLine(content);
return 0;
});
return command;
}
}
Loading
Loading