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
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];
+ }
+}
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;
}
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")));
+ }
+}