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
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions src/ShellDocs.CLI/Commands/BuildCommand.cs
Original file line number Diff line number Diff line change
@@ -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 <base href="/" /> 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 <base href="..." /> 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,
@"<base\s+href\s*=\s*(""[^""]*""|'[^']*'|[^\s>]+)\s*/?>",
$"<base href=\"{baseHref}\" />",
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];
}
}
60 changes: 60 additions & 0 deletions src/ShellDocs.CLI/Commands/DevCommand.cs
Original file line number Diff line number Diff line change
@@ -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];
}
}
44 changes: 35 additions & 9 deletions src/ShellDocs.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,31 +72,57 @@ private static Command CreateNewCommand()

private static Command CreateDevCommand()
{
var dir = new Option<string>("--dir")
{
Description = "Project directory (default: current dir).",
DefaultValueFactory = _ => Directory.GetCurrentDirectory()
};
var port = new Option<int>("--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<string>("--dir")
{
Description = "Project directory (default: current dir).",
DefaultValueFactory = _ => Directory.GetCurrentDirectory()
};
var output = new Option<string>("--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<string?>("--base-href")
{
AnsiConsole.MarkupLine("[yellow]shelldocs build[/] — not yet implemented (feat/cli-dev-build).");
});
Description = "Rewrite <base href> in index.html (e.g. \"/my-repo/\" for GH Pages subpaths)."
};
var spaFallback = new Option<bool>("--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;
}

Expand Down
97 changes: 97 additions & 0 deletions tests/ShellDocs.Tests/BuildCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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("<base href=\"/\" />", "/repo/", "<base href=\"/repo/\" />")]
[InlineData("<base href='/'/>", "/repo/", "<base href=\"/repo/\" />")]
[InlineData("<base href=/ />", "/repo/", "<base href=\"/repo/\" />")]
[InlineData("<BASE HREF=\"/old/\" />", "/new/", "<base href=\"/new/\" />")]
public void RewriteBaseHref_HandlesQuoteVariants(string original, string href, string expected)
{
var path = Path.Combine(_tempDir, "index.html");
File.WriteAllText(path, $"<html><head>{original}</head></html>");
RewriteBaseHref(path, href);
Assert.Contains(expected, File.ReadAllText(path));
}

[Fact]
public void RewriteBaseHref_LeavesOtherMarkupUntouched()
{
var path = Path.Combine(_tempDir, "index.html");
var input = "<html><head><title>App</title><base href=\"/\" /><meta /></head><body></body></html>";
File.WriteAllText(path, input);
RewriteBaseHref(path, "/x/");
var output = File.ReadAllText(path);
Assert.Contains("<title>App</title>", output);
Assert.Contains("<meta />", output);
Assert.Contains("<base href=\"/x/\" />", 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")));
}
}
Loading