From 62cd9bbb1f170610222092cc16a6a3cf5a61572b Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 17 Jul 2026 17:36:48 +0200 Subject: [PATCH 1/4] feat: implement `shelldocs init` command to initialize Blazor projects with ShellDocs scaffolding and package references --- src/ShellDocs.CLI/Commands/InitCommand.cs | 193 ++++++++++++++++++++++ src/ShellDocs.CLI/Program.cs | 17 +- 2 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/ShellDocs.CLI/Commands/InitCommand.cs diff --git a/src/ShellDocs.CLI/Commands/InitCommand.cs b/src/ShellDocs.CLI/Commands/InitCommand.cs new file mode 100644 index 0000000..f840403 --- /dev/null +++ b/src/ShellDocs.CLI/Commands/InitCommand.cs @@ -0,0 +1,193 @@ +using System.Text.RegularExpressions; +using ShellDocs.Templates; +using Spectre.Console; + +namespace ShellDocs.CLI.Commands; + +/* `shelldocs init` — detects the Blazor project in the current directory, + adds ShellDocs package references, and drops in scaffolding for content/ + plus a DocsPage.razor route. Copy-paste snippets for Program.cs + App.razor + land in SHELLDOCS_SETUP.md next to the .csproj rather than being patched + in — the user's project may have auth, custom middleware, etc. we can't + safely rewrite around. + + Idempotent: every file/package check is skip-if-present. */ +internal static class InitCommand +{ + private const string ShellDocsVersion = "0.1.0-alpha"; + + public static int Run(string dir, bool yes, string theme) + { + var root = Path.GetFullPath(dir); + if (!Directory.Exists(root)) + { + AnsiConsole.MarkupLine($"[red]error:[/] directory not found: [yellow]{root}[/]"); + return 1; + } + + var csproj = FindCsproj(root); + if (csproj is null) + { + AnsiConsole.MarkupLine($"[red]error:[/] no .csproj found in [yellow]{root}[/]"); + AnsiConsole.MarkupLine("[dim]Run this inside a Blazor project root.[/]"); + return 1; + } + + if (!IsBlazorProject(csproj)) + { + AnsiConsole.MarkupLine($"[red]error:[/] [yellow]{Path.GetFileName(csproj)}[/] doesn't look like a Blazor project."); + AnsiConsole.MarkupLine("[dim]Expected SDK Microsoft.NET.Sdk.Web or Microsoft.NET.Sdk.Razor with an AspNetCore.Components reference.[/]"); + return 1; + } + + var siteName = InferSiteName(csproj); + var githubRepo = yes ? "" : PromptGithub(); + + AnsiConsole.WriteLine(); + var summary = new Table().Border(TableBorder.Rounded).AddColumn("").AddColumn(""); + summary.HideHeaders(); + summary.AddRow("[bold]Project[/]", $"[yellow]{Path.GetFileName(csproj)}[/]"); + summary.AddRow("[bold]Site name[/]", $"[yellow]{siteName}[/]"); + summary.AddRow("[bold]Theme[/]", $"[yellow]{theme}[/]"); + AnsiConsole.Write(summary); + AnsiConsole.WriteLine(); + + var changes = new List(); + + // 1. Package references + AddPackageIfMissing(csproj, "ShellDocs.Components", ShellDocsVersion, changes); + AddPackageIfMissing(csproj, "ShellDocs.Tokens", ShellDocsVersion, changes); + + // 2. content/docs/ scaffolding + var contentDir = Path.Combine(root, "content", "docs"); + Directory.CreateDirectory(contentDir); + WriteIfMissing(Path.Combine(contentDir, "introduction.md"), ScaffoldTemplates.IntroductionMd, changes); + WriteIfMissing(Path.Combine(contentDir, "meta.json"), ScaffoldTemplates.MetaJson, changes); + + // 3. Components/Pages/DocsPage.razor — the routed page that binds the framework to /docs/* + var pagesDir = LocateOrCreatePagesDir(root); + WriteIfMissing(Path.Combine(pagesDir, "DocsPage.razor"), ScaffoldTemplates.DocsPageRazor, changes); + + // 4. Setup instructions with the Program.cs + App.razor snippets + var setupPath = Path.Combine(root, "SHELLDOCS_SETUP.md"); + WriteIfMissing(setupPath, ScaffoldTemplates.SetupInstructionsMd(siteName, githubRepo), changes); + + // Report + AnsiConsole.WriteLine(); + if (changes.Count == 0) + { + AnsiConsole.MarkupLine("[green]✓[/] Already initialised — nothing to do."); + } + else + { + AnsiConsole.MarkupLine($"[green]✓[/] Wrote [bold]{changes.Count}[/] change(s):"); + foreach (var c in changes) AnsiConsole.MarkupLine($" [dim]•[/] {c}"); + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[bold]Next:[/] follow the snippets in [yellow]SHELLDOCS_SETUP.md[/] to patch Program.cs and App.razor, then:"); + AnsiConsole.MarkupLine(" [dim]$[/] [cyan]dotnet run[/]"); + AnsiConsole.MarkupLine(" [dim]→ visit[/] [cyan]/docs/introduction[/]"); + return 0; + } + + private static string? FindCsproj(string dir) + { + var matches = Directory.GetFiles(dir, "*.csproj", SearchOption.TopDirectoryOnly); + return matches.Length == 0 ? null : matches[0]; + } + + private static bool IsBlazorProject(string csproj) + { + var xml = File.ReadAllText(csproj); + var hasWebSdk = xml.Contains("Sdk=\"Microsoft.NET.Sdk.Web\"", StringComparison.OrdinalIgnoreCase) + || xml.Contains("Sdk=\"Microsoft.NET.Sdk.Razor\"", StringComparison.OrdinalIgnoreCase) + || xml.Contains("Sdk=\"Microsoft.NET.Sdk.BlazorWebAssembly\"", StringComparison.OrdinalIgnoreCase); + var hasBlazorRef = xml.Contains("Microsoft.AspNetCore.Components", StringComparison.OrdinalIgnoreCase); + return hasWebSdk || hasBlazorRef; + } + + private static void AddPackageIfMissing(string csproj, string package, string version, List changes) + { + var xml = File.ReadAllText(csproj); + var pattern = new Regex($@""; + + // Prefer inserting into an existing ItemGroup that already holds PackageReferences. + var itemGroup = Regex.Match(xml, + @"\s*(?=\s*. + var closing = xml.LastIndexOf("", StringComparison.OrdinalIgnoreCase); + if (closing < 0) return; + var block = $" {Environment.NewLine}{reference}{Environment.NewLine} {Environment.NewLine}{Environment.NewLine}"; + patched = xml.Insert(closing, block); + } + + File.WriteAllText(csproj, patched); + changes.Add($"added [cyan]{package}[/] to {Path.GetFileName(csproj)}"); + } + + private static string LocateOrCreatePagesDir(string root) + { + // Common Blazor project layouts: Components/Pages (Web App), Pages (Server / WASM classic). + var candidates = new[] + { + Path.Combine(root, "Components", "Pages"), + Path.Combine(root, "Pages"), + }; + foreach (var c in candidates) + { + if (Directory.Exists(c)) return c; + } + // Default to Components/Pages (modern Blazor Web App layout). + var chosen = candidates[0]; + Directory.CreateDirectory(chosen); + return chosen; + } + + private static void WriteIfMissing(string path, string content, List changes) + { + if (File.Exists(path)) return; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + var rel = Path.GetRelativePath(Directory.GetCurrentDirectory(), path); + changes.Add($"created [cyan]{rel}[/]"); + } + + private static string InferSiteName(string csproj) + { + // Use the .csproj filename minus extension as the default site name. + var name = Path.GetFileNameWithoutExtension(csproj); + return string.IsNullOrEmpty(name) ? "Docs" : name; + } + + private static string PromptGithub() + { + try + { + return AnsiConsole.Prompt( + new TextPrompt("[bold]GitHub repo[/] [dim](owner/repo, blank to skip)[/]:") + .AllowEmpty()); + } + catch + { + // Non-interactive environment (redirected stdin, CI without a TTY). + return ""; + } + } +} diff --git a/src/ShellDocs.CLI/Program.cs b/src/ShellDocs.CLI/Program.cs index 2a17c3d..1a6de6a 100644 --- a/src/ShellDocs.CLI/Program.cs +++ b/src/ShellDocs.CLI/Program.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using ShellDocs.CLI.Commands; using Spectre.Console; namespace ShellDocs.CLI; @@ -29,22 +30,30 @@ private static int Main(string[] args) private static Command CreateInitCommand() { + var dir = new Option("--dir") + { + Description = "Project directory to initialise (default: current dir).", + DefaultValueFactory = _ => Directory.GetCurrentDirectory() + }; var yes = new Option("--yes") { Description = "Non-interactive mode with default options." }; var theme = new Option("--theme") { Description = "Theme preset: shadcn, fuma, nextra.", DefaultValueFactory = _ => "shadcn" }; - var cmd = new Command("init", "Initialize ShellDocs in a Blazor WASM project — adds packages, generates content/ and Layout/, patches Program.cs.") + var cmd = new Command("init", "Initialize ShellDocs in a Blazor project — adds packages, generates content/ and DocsPage.razor, emits Program.cs + App.razor snippets.") { - yes, theme + dir, yes, theme }; - cmd.SetAction(_ => + cmd.SetAction(pr => { AnsiConsole.Markup($"[blue]{Logo}[/]"); AnsiConsole.MarkupLine("[dim] the docs framework for .NET[/]"); AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[yellow]shelldocs init[/] — not yet implemented (feat/cli-init)."); + return InitCommand.Run( + pr.GetValue(dir) ?? Directory.GetCurrentDirectory(), + pr.GetValue(yes), + pr.GetValue(theme) ?? "shadcn"); }); return cmd; } From 679c2c37a761ee80d150d05b0ea3d3ea35650ee6 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 17 Jul 2026 17:37:13 +0200 Subject: [PATCH 2/4] feat: add ScaffoldTemplates class for ShellDocs site initialization templates and setup instructions --- src/ShellDocs.Templates/ScaffoldTemplates.cs | 172 +++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/ShellDocs.Templates/ScaffoldTemplates.cs diff --git a/src/ShellDocs.Templates/ScaffoldTemplates.cs b/src/ShellDocs.Templates/ScaffoldTemplates.cs new file mode 100644 index 0000000..c334bf1 --- /dev/null +++ b/src/ShellDocs.Templates/ScaffoldTemplates.cs @@ -0,0 +1,172 @@ +namespace ShellDocs.Templates; + +/* Templates emitted by `shelldocs init`. Kept as raw string constants so the + CLI has zero I/O overhead — the templates ARE the payload. */ +public static class ScaffoldTemplates +{ + public static string IntroductionMd => """ + --- + title: Introduction + description: Get started with your ShellDocs site. + order: 1 + --- + + # Introduction + + Welcome to your new ShellDocs site. Author markdown in `content/docs/`, drop Blazor components mid-page, ship. + + ## What's next + + - Edit this file at `content/docs/introduction.md` + - Add pages by creating more `.md` files in the same folder + - Order them via `content/docs/meta.json` + - Register components you want available inline via `RegisterComponent()` in `Program.cs` + + ## Live components + + ```razor:preview + + ``` + """; + + public static string MetaJson => """ + { + "title": "Docs", + "pages": ["introduction"] + } + """; + + public static string DocsPageRazor => """ + @page "/docs/{*Path:nonfile}" + @layout DocsLayout + @using Microsoft.AspNetCore.Components.Sections + @inject NavigationGraph Graph + @inject MarkdownRenderer Renderer + + @_title + + @if (_document is not null) + { + + + + + + + } + else + { +
+

Page not found

+

The page @Path doesn't exist yet.

+

← Back to introduction

+
+ } + + @code { + [Parameter] public string? Path { get; set; } + + private RenderedDocument? _document; + private string _title = ""; + private NavigationNode? _prev; + private NavigationNode? _next; + + protected override void OnParametersSet() + { + var url = "/docs" + (string.IsNullOrEmpty(Path) ? "" : "/" + Path); + var node = Graph.ResolveByUrl(url); + if (node?.Path is not null && System.IO.File.Exists(node.Path)) + { + _document = Renderer.RenderFile(node.Path); + _title = node.Title + " — Docs"; + (_prev, _next) = Graph.GetPrevNext(node); + } + else + { + _document = null; + _title = "Not found"; + _prev = null; + _next = null; + } + } + } + """; + + /* Copy-paste snippets the user drops into their own Program.cs / App.razor. + We don't patch those files directly — the user's project may have custom + middleware, auth, etc. we can't safely rewrite around. */ + public static string SetupInstructionsMd(string siteName, string githubRepo) => $$""" + # ShellDocs setup + + Two files in your Blazor project need small additions. Copy these snippets in, then delete this file. + + ## 1. `Program.cs` + + Add near the top with your other usings: + + ```csharp + using ShellDocs.Components; + ``` + + Register the framework before `var app = builder.Build();`: + + ```csharp + builder.WebHost.UseStaticWebAssets(); + + builder.Services.AddShellDocs(o => + { + o.ContentRoot = Path.Combine(builder.Environment.ContentRootPath, "content"); + o.SiteName = "{{siteName}}"; + o.GitHubRepo = "{{githubRepo}}"; + o.AddNavLink("Docs", "/docs/introduction"); + // o.RegisterComponent(); // for razor:preview blocks + }); + ``` + + ## 2. `Components/App.razor` + + Add these two `` tags inside ``, before your app styles: + + ```html + + + ``` + + Add these before `` (below `blazor.web.js` is fine): + + ```html + + + ``` + + And this tiny inline script inside `` (before ``) — bootstraps dark mode before Blazor hydrates so there's no flash: + + ```html + + ``` + + ## 3. Run it + + ```bash + dotnet run + ``` + + Visit `/docs/introduction`. Delete this file when done. + """; +} From f98e75779c319ce5fa2db3ad7a952f94b12cd3a6 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 17 Jul 2026 17:37:40 +0200 Subject: [PATCH 3/4] docs: update ROADMAP to mark `feat/cli-init` as shipped, detailing the `shelldocs init` command functionality for Blazor WASM projects --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index deb595c..ae40fa8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -104,7 +104,7 @@ Ships to `ShellDocs.Components`. - Line-highlight styling via CSS - Handles `razor:preview` blocks — code visible in Preview + Code tabs (`` primitive comes in Phase 2) -### `feat/cli-init` +### ✅ `feat/cli-init` — shipped Ships to `ShellDocs.CLI` + `ShellDocs.Templates`. - `shelldocs init` — detects Blazor WASM project, adds package references, generates `content/`, `Layout/DocsLayout.razor`, patches `Program.cs` to register services, writes default `meta.json` From 551d0b4116f187e5628ac77d7cfe52829a939727 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 17 Jul 2026 17:37:54 +0200 Subject: [PATCH 4/4] test: add integration tests for `shelldocs init` command to validate Blazor project scaffolding and idempotency --- tests/ShellDocs.Tests/InitCommandTests.cs | 125 +++++++++++++++++++ tests/ShellDocs.Tests/ShellDocs.Tests.csproj | 1 + 2 files changed, 126 insertions(+) create mode 100644 tests/ShellDocs.Tests/InitCommandTests.cs diff --git a/tests/ShellDocs.Tests/InitCommandTests.cs b/tests/ShellDocs.Tests/InitCommandTests.cs new file mode 100644 index 0000000..c0c7cc9 --- /dev/null +++ b/tests/ShellDocs.Tests/InitCommandTests.cs @@ -0,0 +1,125 @@ +using System.Reflection; +using Xunit; + +namespace ShellDocs.Tests; + +/* Integration tests for `shelldocs init` — spin up a minimal Blazor csproj in a + temp dir, invoke InitCommand.Run via reflection (it's internal), assert the + scaffolding lands and is idempotent. */ +public class InitCommandTests : IDisposable +{ + private readonly string _tempDir; + private readonly MethodInfo _run; + + public InitCommandTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "shelldocs-init-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + var cli = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "shelldocs") + ?? Assembly.Load("shelldocs"); + var type = cli.GetType("ShellDocs.CLI.Commands.InitCommand", throwOnError: true)!; + _run = type.GetMethod("Run", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + private void WriteBlazorCsproj(string content = null!) => + File.WriteAllText(Path.Combine(_tempDir, "TestApp.csproj"), + content ?? """ + + + net10.0 + + + + + + """); + + private int Invoke() => (int)_run.Invoke(null, new object[] { _tempDir, true, "shadcn" })!; + + [Fact] + public void Init_MissingCsproj_ReturnsError() + { + var code = Invoke(); + Assert.Equal(1, code); + } + + [Fact] + public void Init_NonBlazorCsproj_ReturnsError() + { + File.WriteAllText(Path.Combine(_tempDir, "TestApp.csproj"), + """net10.0"""); + var code = Invoke(); + Assert.Equal(1, code); + } + + [Fact] + public void Init_ValidBlazorProject_ScaffoldsContentAndPage() + { + WriteBlazorCsproj(); + var code = Invoke(); + Assert.Equal(0, code); + + Assert.True(File.Exists(Path.Combine(_tempDir, "content", "docs", "introduction.md"))); + Assert.True(File.Exists(Path.Combine(_tempDir, "content", "docs", "meta.json"))); + Assert.True(File.Exists(Path.Combine(_tempDir, "Components", "Pages", "DocsPage.razor"))); + Assert.True(File.Exists(Path.Combine(_tempDir, "SHELLDOCS_SETUP.md"))); + } + + [Fact] + public void Init_AddsShellDocsPackagesToCsproj() + { + WriteBlazorCsproj(); + Invoke(); + var csproj = File.ReadAllText(Path.Combine(_tempDir, "TestApp.csproj")); + Assert.Contains("ShellDocs.Components", csproj); + Assert.Contains("ShellDocs.Tokens", csproj); + } + + [Fact] + public void Init_RunTwice_IsIdempotent() + { + WriteBlazorCsproj(); + Assert.Equal(0, Invoke()); + var csprojAfterFirst = File.ReadAllText(Path.Combine(_tempDir, "TestApp.csproj")); + var mdAfterFirst = File.ReadAllText(Path.Combine(_tempDir, "content", "docs", "introduction.md")); + + Assert.Equal(0, Invoke()); + var csprojAfterSecond = File.ReadAllText(Path.Combine(_tempDir, "TestApp.csproj")); + var mdAfterSecond = File.ReadAllText(Path.Combine(_tempDir, "content", "docs", "introduction.md")); + + Assert.Equal(csprojAfterFirst, csprojAfterSecond); + Assert.Equal(mdAfterFirst, mdAfterSecond); + } + + [Fact] + public void Init_PreservesUserModifications_OnRerun() + { + WriteBlazorCsproj(); + Invoke(); + var mdPath = Path.Combine(_tempDir, "content", "docs", "introduction.md"); + File.WriteAllText(mdPath, "# My custom intro\n"); + + Invoke(); + + Assert.Equal("# My custom intro\n", File.ReadAllText(mdPath)); + } + + [Fact] + public void Init_UsesExistingPagesDir_WhenPresent() + { + WriteBlazorCsproj(); + var altPages = Path.Combine(_tempDir, "Pages"); + Directory.CreateDirectory(altPages); + Invoke(); + + Assert.True(File.Exists(Path.Combine(altPages, "DocsPage.razor"))); + Assert.False(Directory.Exists(Path.Combine(_tempDir, "Components", "Pages"))); + } +} diff --git a/tests/ShellDocs.Tests/ShellDocs.Tests.csproj b/tests/ShellDocs.Tests/ShellDocs.Tests.csproj index 9474b0c..8bf6c65 100644 --- a/tests/ShellDocs.Tests/ShellDocs.Tests.csproj +++ b/tests/ShellDocs.Tests/ShellDocs.Tests.csproj @@ -11,6 +11,7 @@ +