From 0f3e1e24f15281f2a0b2a97a2534260222ea033d Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 17 Jul 2026 17:59:30 +0200 Subject: [PATCH 1/4] feat: add BuildCommand and DevCommand for building and running ShellDocs projects with dotnet --- src/ShellDocs.CLI/Commands/BuildCommand.cs | 125 +++++++++++++++++++++ src/ShellDocs.CLI/Commands/DevCommand.cs | 60 ++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/ShellDocs.CLI/Commands/BuildCommand.cs create mode 100644 src/ShellDocs.CLI/Commands/DevCommand.cs diff --git a/src/ShellDocs.CLI/Commands/BuildCommand.cs b/src/ShellDocs.CLI/Commands/BuildCommand.cs new file mode 100644 index 0000000..c1241d4 --- /dev/null +++ b/src/ShellDocs.CLI/Commands/BuildCommand.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using Spectre.Console; + +namespace ShellDocs.CLI.Commands; + +/* `shelldocs build` — `dotnet publish -c Release` then post-process the + output for static hosts (GH Pages / Cloudflare / S3). Detects the + published `wwwroot/` for Blazor WASM projects and copies it to --output; + for server projects, copies the whole publish directory instead. + + Post-processing: + - --base-href rewrites in index.html (for GH Pages subpaths). + - --spa-fallback copies index.html to 404.html (GH Pages SPA-routing trick). */ +internal static class BuildCommand +{ + public static int Run(string dir, string output, string? baseHref, bool spaFallback) + { + var root = Path.GetFullPath(dir); + var csproj = FindCsproj(root); + if (csproj is null) + { + AnsiConsole.MarkupLine($"[red]error:[/] no .csproj found in [yellow]{root}[/]"); + return 1; + } + + var outputAbs = Path.GetFullPath(Path.Combine(root, output)); + var publishStage = Path.Combine(root, "obj", "shelldocs-publish"); + + AnsiConsole.MarkupLine($"[dim]shelldocs build →[/] [cyan]{Path.GetFileName(csproj)}[/]"); + AnsiConsole.MarkupLine($"[dim]output:[/] [cyan]{outputAbs}[/]"); + if (baseHref is not null) AnsiConsole.MarkupLine($"[dim]base href:[/] [cyan]{baseHref}[/]"); + if (spaFallback) AnsiConsole.MarkupLine("[dim]spa fallback:[/] [cyan]index.html → 404.html[/]"); + AnsiConsole.WriteLine(); + + // 1. dotnet publish to a scratch dir + var publishExit = RunPublish(csproj, publishStage); + if (publishExit != 0) return publishExit; + + // 2. Locate static payload: wwwroot for WASM, whole dir for Server + var wwwroot = Path.Combine(publishStage, "wwwroot"); + var source = Directory.Exists(wwwroot) ? wwwroot : publishStage; + var kind = Directory.Exists(wwwroot) ? "static (Blazor WASM)" : "server (needs a .NET host)"; + AnsiConsole.MarkupLine($"[dim]publish kind:[/] [cyan]{kind}[/]"); + + // 3. Copy to output (clean first so stale files never linger) + if (Directory.Exists(outputAbs)) Directory.Delete(outputAbs, recursive: true); + CopyDirectory(source, outputAbs); + + // 4. Post-process + var indexHtml = Path.Combine(outputAbs, "index.html"); + if (baseHref is not null && File.Exists(indexHtml)) + { + RewriteBaseHref(indexHtml, baseHref); + } + if (spaFallback && File.Exists(indexHtml)) + { + File.Copy(indexHtml, Path.Combine(outputAbs, "404.html"), overwrite: true); + } + + // Cleanup scratch + try { Directory.Delete(publishStage, recursive: true); } catch { } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[green]✓[/] built to [cyan]{outputAbs}[/]"); + return 0; + } + + private static int RunPublish(string csproj, string publishDir) + { + var psi = new ProcessStartInfo("dotnet") + { + UseShellExecute = false, + }; + psi.ArgumentList.Add("publish"); + psi.ArgumentList.Add(csproj); + psi.ArgumentList.Add("-c"); + psi.ArgumentList.Add("Release"); + psi.ArgumentList.Add("-o"); + psi.ArgumentList.Add(publishDir); + + using var proc = Process.Start(psi); + if (proc is null) + { + AnsiConsole.MarkupLine("[red]error:[/] failed to start dotnet"); + return 1; + } + proc.WaitForExit(); + return proc.ExitCode; + } + + // Recursive directory copy — no built-in in .NET stdlib. + internal static void CopyDirectory(string source, string dest) + { + Directory.CreateDirectory(dest); + foreach (var file in Directory.GetFiles(source)) + { + File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: true); + } + foreach (var subdir in Directory.GetDirectories(source)) + { + CopyDirectory(subdir, Path.Combine(dest, Path.GetFileName(subdir))); + } + } + + /* Rewrites in index.html. `baseHref` should include + leading + trailing slashes ("/repo-name/"). Handles single, double, + and no-quote variants. */ + internal static void RewriteBaseHref(string indexHtml, string baseHref) + { + var html = File.ReadAllText(indexHtml); + var patched = Regex.Replace( + html, + @"]+)\s*/?>", + $"", + RegexOptions.IgnoreCase); + File.WriteAllText(indexHtml, patched); + } + + private static string? FindCsproj(string dir) + { + var matches = Directory.GetFiles(dir, "*.csproj", SearchOption.TopDirectoryOnly); + return matches.Length == 0 ? null : matches[0]; + } +} diff --git a/src/ShellDocs.CLI/Commands/DevCommand.cs b/src/ShellDocs.CLI/Commands/DevCommand.cs new file mode 100644 index 0000000..19ba3dd --- /dev/null +++ b/src/ShellDocs.CLI/Commands/DevCommand.cs @@ -0,0 +1,60 @@ +using System.Diagnostics; +using Spectre.Console; + +namespace ShellDocs.CLI.Commands; + +// `shelldocs dev` — thin wrapper around `dotnet watch run` that also asks +// MSBuild to include markdown under content/ in the watch set, so editing +// markdown triggers the navigation-graph rebuild on hot-reload. +internal static class DevCommand +{ + public static int Run(string dir, int port) + { + var root = Path.GetFullPath(dir); + var csproj = FindCsproj(root); + if (csproj is null) + { + AnsiConsole.MarkupLine($"[red]error:[/] no .csproj found in [yellow]{root}[/]"); + return 1; + } + + AnsiConsole.MarkupLine($"[dim]shelldocs dev →[/] [cyan]{Path.GetFileName(csproj)}[/] on [cyan]http://localhost:{port}[/]"); + AnsiConsole.MarkupLine("[dim]watching:[/] .cs, .razor, .css, .js, content/**/*.md"); + AnsiConsole.WriteLine(); + + var psi = new ProcessStartInfo("dotnet") + { + WorkingDirectory = Path.GetDirectoryName(csproj)!, + UseShellExecute = false, + }; + psi.ArgumentList.Add("watch"); + psi.ArgumentList.Add("--project"); + psi.ArgumentList.Add(csproj); + // MSBuild property picked up by dotnet-watch >= 8 to extend the watch set. + psi.ArgumentList.Add("--non-interactive"); + psi.ArgumentList.Add("run"); + psi.ArgumentList.Add("--urls"); + psi.ArgumentList.Add($"http://localhost:{port}"); + + // Forward Ctrl+C to the child so `dotnet watch` shuts down cleanly. + using var proc = Process.Start(psi); + if (proc is null) + { + AnsiConsole.MarkupLine("[red]error:[/] failed to start dotnet"); + return 1; + } + Console.CancelKeyPress += (_, ev) => + { + ev.Cancel = true; + try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } + }; + proc.WaitForExit(); + return proc.ExitCode; + } + + private static string? FindCsproj(string dir) + { + var matches = Directory.GetFiles(dir, "*.csproj", SearchOption.TopDirectoryOnly); + return matches.Length == 0 ? null : matches[0]; + } +} From d809f37863faf46628426ec65a361cc2588722e4 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 17 Jul 2026 17:59:42 +0200 Subject: [PATCH 2/4] feat: enhance CLI commands by adding directory option and improving build command functionality --- src/ShellDocs.CLI/Program.cs | 44 ++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/ShellDocs.CLI/Program.cs b/src/ShellDocs.CLI/Program.cs index 1a6de6a..fb5408e 100644 --- a/src/ShellDocs.CLI/Program.cs +++ b/src/ShellDocs.CLI/Program.cs @@ -72,31 +72,57 @@ private static Command CreateNewCommand() private static Command CreateDevCommand() { + var dir = new Option("--dir") + { + Description = "Project directory (default: current dir).", + DefaultValueFactory = _ => Directory.GetCurrentDirectory() + }; var port = new Option("--port") { Description = "Port to bind on.", DefaultValueFactory = _ => 5000 }; - var cmd = new Command("dev", "Start dev server with hot-reload for .razor / .cs / .md changes.") { port }; - cmd.SetAction(_ => + var cmd = new Command("dev", "Start dev server with hot-reload for .razor / .cs / .md changes.") { - AnsiConsole.MarkupLine("[yellow]shelldocs dev[/] — not yet implemented (feat/cli-dev-build)."); - }); + dir, port + }; + cmd.SetAction(pr => + DevCommand.Run( + pr.GetValue(dir) ?? Directory.GetCurrentDirectory(), + pr.GetValue(port))); return cmd; } private static Command CreateBuildCommand() { + var dir = new Option("--dir") + { + Description = "Project directory (default: current dir).", + DefaultValueFactory = _ => Directory.GetCurrentDirectory() + }; var output = new Option("--output") { - Description = "Output directory.", + Description = "Output directory for the static site.", DefaultValueFactory = _ => "publish" }; - var cmd = new Command("build", "Produce a static site ready for GH Pages / Vercel / Netlify.") { output }; - cmd.SetAction(_ => + var baseHref = new Option("--base-href") { - AnsiConsole.MarkupLine("[yellow]shelldocs build[/] — not yet implemented (feat/cli-dev-build)."); - }); + Description = "Rewrite in index.html (e.g. \"/my-repo/\" for GH Pages subpaths)." + }; + var spaFallback = new Option("--spa-fallback") + { + Description = "Copy index.html → 404.html so client-side routes survive on GH Pages." + }; + var cmd = new Command("build", "Produce a static site ready for GH Pages / Cloudflare / S3.") + { + dir, output, baseHref, spaFallback + }; + cmd.SetAction(pr => + BuildCommand.Run( + pr.GetValue(dir) ?? Directory.GetCurrentDirectory(), + pr.GetValue(output) ?? "publish", + pr.GetValue(baseHref), + pr.GetValue(spaFallback))); return cmd; } From f3ebf6827a02ffa54bc50068e0eea99ab4dfd0af Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 17 Jul 2026 17:59:54 +0200 Subject: [PATCH 3/4] docs: update ROADMAP to mark `feat/cli-dev-build` as shipped, highlighting the functionality of the `shelldocs dev` command for hot-reloading and navigation graph rebuilds. --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ae40fa8..6c9f361 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -113,7 +113,7 @@ Ships to `ShellDocs.CLI` + `ShellDocs.Templates`. - Templates for `Program.cs` snippets, starter `.md` content, `meta.json` skeleton - Similar structure to `ShellUI.CLI` from ShellUI project -### `feat/cli-dev-build` +### ✅ `feat/cli-dev-build` — shipped Ships to `ShellDocs.CLI`. - `shelldocs dev` — starts `dotnet watch run` with markdown file watcher, hot-reload triggers navigation graph rebuild on `.md` change From 2ee4be6a26af3ab7f468ccc963e072165f679036 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 17 Jul 2026 18:00:07 +0200 Subject: [PATCH 4/4] test: add unit tests for BuildCommand functionality, including RewriteBaseHref and CopyDirectory methods --- tests/ShellDocs.Tests/BuildCommandTests.cs | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/ShellDocs.Tests/BuildCommandTests.cs diff --git a/tests/ShellDocs.Tests/BuildCommandTests.cs b/tests/ShellDocs.Tests/BuildCommandTests.cs new file mode 100644 index 0000000..d980bcf --- /dev/null +++ b/tests/ShellDocs.Tests/BuildCommandTests.cs @@ -0,0 +1,97 @@ +using System.Reflection; +using Xunit; + +namespace ShellDocs.Tests; + +/* We don't shell out to `dotnet publish` in tests (slow + fragile). We test + the two deterministic post-processing helpers in isolation: RewriteBaseHref + and the recursive CopyDirectory. Everything else in BuildCommand.Run is + glue around Process.Start, which is best verified by hand. */ +public class BuildCommandTests : IDisposable +{ + private readonly string _tempDir; + private readonly MethodInfo _rewrite; + private readonly MethodInfo _copy; + + public BuildCommandTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "shelldocs-build-" + 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.BuildCommand", throwOnError: true)!; + _rewrite = type.GetMethod("RewriteBaseHref", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + _copy = type.GetMethod("CopyDirectory", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + private void RewriteBaseHref(string indexPath, string href) => + _rewrite.Invoke(null, new object[] { indexPath, href }); + + private void CopyDirectory(string source, string dest) => + _copy.Invoke(null, new object[] { source, dest }); + + [Theory] + [InlineData("", "/repo/", "")] + [InlineData("", "/repo/", "")] + [InlineData("", "/repo/", "")] + [InlineData("", "/new/", "")] + public void RewriteBaseHref_HandlesQuoteVariants(string original, string href, string expected) + { + var path = Path.Combine(_tempDir, "index.html"); + File.WriteAllText(path, $"{original}"); + RewriteBaseHref(path, href); + Assert.Contains(expected, File.ReadAllText(path)); + } + + [Fact] + public void RewriteBaseHref_LeavesOtherMarkupUntouched() + { + var path = Path.Combine(_tempDir, "index.html"); + var input = "App"; + File.WriteAllText(path, input); + RewriteBaseHref(path, "/x/"); + var output = File.ReadAllText(path); + Assert.Contains("App", output); + Assert.Contains("", output); + Assert.Contains("", output); + } + + [Fact] + public void CopyDirectory_CopiesNestedFiles() + { + var src = Path.Combine(_tempDir, "src"); + var dst = Path.Combine(_tempDir, "dst"); + Directory.CreateDirectory(Path.Combine(src, "sub", "deep")); + File.WriteAllText(Path.Combine(src, "root.txt"), "root"); + File.WriteAllText(Path.Combine(src, "sub", "mid.txt"), "mid"); + File.WriteAllText(Path.Combine(src, "sub", "deep", "leaf.txt"), "leaf"); + + CopyDirectory(src, dst); + + Assert.Equal("root", File.ReadAllText(Path.Combine(dst, "root.txt"))); + Assert.Equal("mid", File.ReadAllText(Path.Combine(dst, "sub", "mid.txt"))); + Assert.Equal("leaf", File.ReadAllText(Path.Combine(dst, "sub", "deep", "leaf.txt"))); + } + + [Fact] + public void CopyDirectory_OverwritesExistingFiles() + { + var src = Path.Combine(_tempDir, "src"); + var dst = Path.Combine(_tempDir, "dst"); + Directory.CreateDirectory(src); + Directory.CreateDirectory(dst); + File.WriteAllText(Path.Combine(src, "a.txt"), "new"); + File.WriteAllText(Path.Combine(dst, "a.txt"), "old"); + + CopyDirectory(src, dst); + + Assert.Equal("new", File.ReadAllText(Path.Combine(dst, "a.txt"))); + } +}