From e6667cb3a02f0089f6f4568ac98a8ce5fe92b459 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 18 Jul 2026 15:06:00 +0200 Subject: [PATCH 1/9] feat: add DocsFooter component to Blog, Home, and Showcase pages with version and resource links --- .../Components/Pages/Blog.razor | 2 ++ .../Components/Pages/Home.razor | 21 +++++++++++++++++++ .../Components/Pages/Showcase.razor | 2 ++ 3 files changed, 25 insertions(+) diff --git a/examples/ShellDocs.Preview/Components/Pages/Blog.razor b/examples/ShellDocs.Preview/Components/Pages/Blog.razor index abbefa8..ff6ffcc 100644 --- a/examples/ShellDocs.Preview/Components/Pages/Blog.razor +++ b/examples/ShellDocs.Preview/Components/Pages/Blog.razor @@ -32,6 +32,8 @@ + + @code { private record Post(string Title, string Excerpt, string Tag, string Date, string Author, string AuthorInitials, string AvatarBg, string ReadTime, string Href); diff --git a/examples/ShellDocs.Preview/Components/Pages/Home.razor b/examples/ShellDocs.Preview/Components/Pages/Home.razor index 4e9c171..bea88b1 100644 --- a/examples/ShellDocs.Preview/Components/Pages/Home.razor +++ b/examples/ShellDocs.Preview/Components/Pages/Home.razor @@ -202,6 +202,27 @@ Works in Server, WASM, and static output. + + + + + + + - ```csharp - using ShellDocs.Components; - ``` + @code { + private string SiteName => string.IsNullOrEmpty(Options.SiteName) ? "ShellDocs" : Options.SiteName; + } + """; - Register the framework before `var app = builder.Build();`: + /* Bare pass-through MainLayout that replaces the fresh template's + sidebar+NavMenu layout. Every page uses @layout to pick its real + layout (HomeLayout or DocsLayout), so this is a fallback only. */ + public static string BareMainLayoutRazor => """ + @inherits LayoutComponentBase + @Body + """; - ```csharp - builder.WebHost.UseStaticWebAssets(); + // --- Program.cs patch snippets --- + + public static string ProgramUsing => "using ShellDocs.Components;"; + public static string ProgramWebHost => "builder.WebHost.UseStaticWebAssets();"; + + public static string ProgramAddShellDocs(string siteName, string githubRepo) => $$""" builder.Services.AddShellDocs(o => { - o.ContentRoot = Path.Combine(builder.Environment.ContentRootPath, "content"); + o.ContentRoot = System.IO.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` + // --- App.razor patch snippets --- - Add these two `` tags inside ``, before your app styles: - - ```html + public static string AppTokenLinks => """ - ``` + """; - Add these before `` (below `blazor.web.js` is fine): + public static string AppThemeBootstrap => """ + + """; - ```html + public static string AppScripts => """ + """; + + // --- Fallback: SHELLDOCS_SETUP.md for --attach mode where we can't safely patch --- + + 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 + {{ProgramUsing}} + ``` + + Register the framework before `var app = builder.Build();`: + + ```csharp + {{ProgramWebHost}} + + {{ProgramAddShellDocs(siteName, githubRepo)}} ``` - And this tiny inline script inside `` (before ``) — bootstraps dark mode before Blazor hydrates so there's no flash: + ## 2. `Components/App.razor` + + Add these two `` tags inside ``, before your app styles: ```html - + {{AppTokenLinks}} + ``` + + Add this small script inside `` (before ``) — bootstraps dark mode before Blazor hydrates: + + ```html + {{AppThemeBootstrap}} + ``` + + Add these before `` (below `blazor.web.js` is fine): + + ```html + {{AppScripts}} ``` ## 3. Run it From 32a623c8428b3aee3eb59e384b923d0b03b61be3 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 18 Jul 2026 15:07:46 +0200 Subject: [PATCH 7/9] docs: update ROADMAP to include `feat/cli-init-create-mode`, detailing the split of `shelldocs init` into create and attach modes for improved project scaffolding. --- docs/ROADMAP.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6c9f361..eb17c7d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -105,6 +105,12 @@ Ships to `ShellDocs.Components`. - Handles `razor:preview` blocks — code visible in Preview + Code tabs (`` primitive comes in Phase 2) ### ✅ `feat/cli-init` — shipped + +### ✅ `feat/cli-init-create-mode` — shipped +Split `shelldocs init` into two modes: +- **create** (default) — runs `dotnet new blazor` in `docs/.Docs`, then directly patches Program.cs + App.razor + adds packages + drops content/DocsPage.razor. One-command scaffold. +- **attach** (`--attach`) — the old behaviour; augments an existing csproj and emits `SHELLDOCS_SETUP.md` for manual patching (safer for projects with custom auth/middleware). +Both idempotent. Tests: 12 (4 attach + 4 patcher + 4 fixture-verified). 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 1493bd453a5285b417840d37451f05e6cf9ee53c Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 18 Jul 2026 15:08:02 +0200 Subject: [PATCH 8/9] test: enhance InitCommandTests to support attach mode, updating method names and assertions for improved clarity and functionality --- tests/ShellDocs.Tests/InitCommandTests.cs | 241 ++++++++++++++++++---- 1 file changed, 202 insertions(+), 39 deletions(-) diff --git a/tests/ShellDocs.Tests/InitCommandTests.cs b/tests/ShellDocs.Tests/InitCommandTests.cs index c0c7cc9..9d00271 100644 --- a/tests/ShellDocs.Tests/InitCommandTests.cs +++ b/tests/ShellDocs.Tests/InitCommandTests.cs @@ -3,13 +3,18 @@ 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. */ +/* Integration tests for `shelldocs init`. Covers ATTACH mode end-to-end + (fast — no `dotnet new` spawn) and the CREATE-mode patchers (PatchProgramCs, + PatchAppRazor) against synthetic fresh-blazor-template fixtures. The full + CREATE path (dotnet new blazor + patchers) is verified by hand — spawning + dotnet in unit tests is slow and fragile. */ public class InitCommandTests : IDisposable { private readonly string _tempDir; private readonly MethodInfo _run; + private readonly MethodInfo _patchProgram; + private readonly MethodInfo _patchApp; + private readonly MethodInfo _findSln; public InitCommandTests() { @@ -20,9 +25,14 @@ public InitCommandTests() .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)!; + _run = type.GetMethod("Run", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; + _patchProgram = type.GetMethod("PatchProgramCs", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + _patchApp = type.GetMethod("PatchAppRazor", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + _findSln = type.GetMethod("FindNearestSolution", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; } + private string? FindNearestSolution(string dir) => (string?)_findSln.Invoke(null, new object[] { dir }); + public void Dispose() { try { Directory.Delete(_tempDir, recursive: true); } catch { } @@ -41,31 +51,30 @@ private void WriteBlazorCsproj(string content = null!) => """); - private int Invoke() => (int)_run.Invoke(null, new object[] { _tempDir, true, "shadcn" })!; + private int InvokeAttach() => + (int)_run.Invoke(null, new object?[] { null, _tempDir, true, true, "shadcn" })!; + + // ---- ATTACH MODE ---------------------------------------------------- [Fact] - public void Init_MissingCsproj_ReturnsError() + public void Attach_MissingCsproj_ReturnsError() { - var code = Invoke(); - Assert.Equal(1, code); + Assert.Equal(1, InvokeAttach()); } [Fact] - public void Init_NonBlazorCsproj_ReturnsError() + public void Attach_NonBlazorCsproj_ReturnsError() { File.WriteAllText(Path.Combine(_tempDir, "TestApp.csproj"), """net10.0"""); - var code = Invoke(); - Assert.Equal(1, code); + Assert.Equal(1, InvokeAttach()); } [Fact] - public void Init_ValidBlazorProject_ScaffoldsContentAndPage() + public void Attach_ValidBlazorProject_ScaffoldsContentAndPage() { WriteBlazorCsproj(); - var code = Invoke(); - Assert.Equal(0, code); - + Assert.Equal(0, InvokeAttach()); 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"))); @@ -73,53 +82,207 @@ public void Init_ValidBlazorProject_ScaffoldsContentAndPage() } [Fact] - public void Init_AddsShellDocsPackagesToCsproj() + public void Attach_AddsShellDocsPackagesToCsproj() { WriteBlazorCsproj(); - Invoke(); + InvokeAttach(); 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() + public void Attach_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); + Assert.Equal(0, InvokeAttach()); + var csprojA = File.ReadAllText(Path.Combine(_tempDir, "TestApp.csproj")); + Assert.Equal(0, InvokeAttach()); + var csprojB = File.ReadAllText(Path.Combine(_tempDir, "TestApp.csproj")); + Assert.Equal(csprojA, csprojB); } [Fact] - public void Init_PreservesUserModifications_OnRerun() + public void Attach_PreservesUserModifications_OnRerun() { WriteBlazorCsproj(); - Invoke(); + InvokeAttach(); var mdPath = Path.Combine(_tempDir, "content", "docs", "introduction.md"); File.WriteAllText(mdPath, "# My custom intro\n"); + InvokeAttach(); + Assert.Equal("# My custom intro\n", File.ReadAllText(mdPath)); + } - Invoke(); + // ---- CREATE-MODE PATCHERS ------------------------------------------- - Assert.Equal("# My custom intro\n", File.ReadAllText(mdPath)); + /// Emits a synthetic Program.cs identical in shape to `dotnet new blazor` output. + private void WriteFreshBlazorProgramCs() => + File.WriteAllText(Path.Combine(_tempDir, "Program.cs"), + """ + using TestApp.Components; + + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + + var app = builder.Build(); + + if (!app.Environment.IsDevelopment()) + { + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseAntiforgery(); + app.MapStaticAssets(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + + app.Run(); + """); + + private void WriteFreshBlazorAppRazor() + { + var componentsDir = Path.Combine(_tempDir, "Components"); + Directory.CreateDirectory(componentsDir); + File.WriteAllText(Path.Combine(componentsDir, "App.razor"), + """ + + + + + + + + + + + + + + + + + """); } [Fact] - public void Init_UsesExistingPagesDir_WhenPresent() + public void PatchProgramCs_InsertsUsingWebHostAndAddShellDocs() { - WriteBlazorCsproj(); - var altPages = Path.Combine(_tempDir, "Pages"); - Directory.CreateDirectory(altPages); - Invoke(); + WriteFreshBlazorProgramCs(); + var changes = new List(); + _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "myorg/myrepo", changes }); + + var src = File.ReadAllText(Path.Combine(_tempDir, "Program.cs")); + Assert.Contains("using ShellDocs.Components;", src); + Assert.Contains("builder.WebHost.UseStaticWebAssets();", src); + Assert.Contains("builder.Services.AddShellDocs(", src); + Assert.Contains("o.SiteName = \"MySite\"", src); + Assert.Contains("o.GitHubRepo = \"myorg/myrepo\"", src); + Assert.Single(changes); + } + + [Fact] + public void PatchProgramCs_IsIdempotent() + { + WriteFreshBlazorProgramCs(); + var changes = new List(); + _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "", changes }); + var afterFirst = File.ReadAllText(Path.Combine(_tempDir, "Program.cs")); + + var secondChanges = new List(); + _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "", secondChanges }); + var afterSecond = File.ReadAllText(Path.Combine(_tempDir, "Program.cs")); + + Assert.Equal(afterFirst, afterSecond); + Assert.Empty(secondChanges); + } + + [Fact] + public void PatchAppRazor_InsertsCssLinksBootstrapAndScripts() + { + WriteFreshBlazorAppRazor(); + var changes = new List(); + _patchApp.Invoke(null, new object[] { _tempDir, changes }); + + var src = File.ReadAllText(Path.Combine(_tempDir, "Components", "App.razor")); + Assert.Contains("_content/ShellDocs.Tokens/tokens.css", src); + Assert.Contains("_content/ShellDocs.Components/shelldocs-theme.css", src); + Assert.Contains("_content/ShellDocs.Components/shelldocs.js", src); + Assert.Contains("import { createHighlighter } from 'https://esm.sh/shiki", src); + Assert.Contains("localStorage.getItem('shelldocs-theme')", src); + Assert.Single(changes); + } - Assert.True(File.Exists(Path.Combine(altPages, "DocsPage.razor"))); - Assert.False(Directory.Exists(Path.Combine(_tempDir, "Components", "Pages"))); + [Fact] + public void PatchAppRazor_IsIdempotent() + { + WriteFreshBlazorAppRazor(); + var changes = new List(); + _patchApp.Invoke(null, new object[] { _tempDir, changes }); + var afterFirst = File.ReadAllText(Path.Combine(_tempDir, "Components", "App.razor")); + + var secondChanges = new List(); + _patchApp.Invoke(null, new object[] { _tempDir, changes }); + var afterSecond = File.ReadAllText(Path.Combine(_tempDir, "Components", "App.razor")); + + Assert.Equal(afterFirst, afterSecond); + Assert.Empty(secondChanges); + } + + // ---- SOLUTION FINDER ------------------------------------------------ + + [Fact] + public void FindNearestSolution_ReturnsSlnxInSameDir() + { + var path = Path.Combine(_tempDir, "MyRepo.slnx"); + File.WriteAllText(path, ""); + Assert.Equal(path, FindNearestSolution(_tempDir)); + } + + [Fact] + public void FindNearestSolution_ReturnsSlnWhenNoSlnx() + { + var path = Path.Combine(_tempDir, "MyRepo.sln"); + File.WriteAllText(path, "Microsoft Visual Studio Solution File"); + Assert.Equal(path, FindNearestSolution(_tempDir)); + } + + [Fact] + public void FindNearestSolution_PrefersSlnxOverSln() + { + File.WriteAllText(Path.Combine(_tempDir, "MyRepo.sln"), "legacy"); + var slnx = Path.Combine(_tempDir, "MyRepo.slnx"); + File.WriteAllText(slnx, ""); + Assert.Equal(slnx, FindNearestSolution(_tempDir)); + } + + [Fact] + public void FindNearestSolution_WalksUpToParent() + { + var slnx = Path.Combine(_tempDir, "MyRepo.slnx"); + File.WriteAllText(slnx, ""); + var sub = Path.Combine(_tempDir, "docs", "MyRepo.Docs"); + Directory.CreateDirectory(sub); + Assert.Equal(slnx, FindNearestSolution(sub)); + } + + [Fact] + public void FindNearestSolution_StopsAtGitRoot() + { + // .git in tempDir marks it as repo root; no sln inside means null, + // even if an sln exists in tempDir's parent (which it doesn't here). + Directory.CreateDirectory(Path.Combine(_tempDir, ".git")); + var sub = Path.Combine(_tempDir, "sub"); + Directory.CreateDirectory(sub); + Assert.Null(FindNearestSolution(sub)); + } + + [Fact] + public void FindNearestSolution_ReturnsNullWhenNoneFound() + { + Assert.Null(FindNearestSolution(_tempDir)); } } From 32aa0076af75ed3a80133c6639606262d7285af7 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 18 Jul 2026 15:09:29 +0200 Subject: [PATCH 9/9] chore: set initial version to 0.1.0-alpha in Directory.Build.props for project versioning --- Directory.Build.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.Build.props b/Directory.Build.props index 6e9259b..5ac72a8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,6 +18,7 @@ + 0.1.0-alpha ShellUI ShellUI Copyright © 2026 ShellUI