Skip to content

Commit ee4039b

Browse files
authored
Merge pull request #8 from shellui-dev/feat/cli-dev-build
feat: shelldocs dev + build — hot-reload watcher and static-site publisher
2 parents 1c85058 + 2ee4be6 commit ee4039b

5 files changed

Lines changed: 318 additions & 10 deletions

File tree

docs/ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Ships to `ShellDocs.CLI` + `ShellDocs.Templates`.
113113
- Templates for `Program.cs` snippets, starter `.md` content, `meta.json` skeleton
114114
- Similar structure to `ShellUI.CLI` from ShellUI project
115115

116-
### `feat/cli-dev-build`
116+
### `feat/cli-dev-build` — shipped
117117
Ships to `ShellDocs.CLI`.
118118

119119
- `shelldocs dev` — starts `dotnet watch run` with markdown file watcher, hot-reload triggers navigation graph rebuild on `.md` change
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using System.Diagnostics;
2+
using System.Text.RegularExpressions;
3+
using Spectre.Console;
4+
5+
namespace ShellDocs.CLI.Commands;
6+
7+
/* `shelldocs build` — `dotnet publish -c Release` then post-process the
8+
output for static hosts (GH Pages / Cloudflare / S3). Detects the
9+
published `wwwroot/` for Blazor WASM projects and copies it to --output;
10+
for server projects, copies the whole publish directory instead.
11+
12+
Post-processing:
13+
- --base-href rewrites <base href="/" /> in index.html (for GH Pages subpaths).
14+
- --spa-fallback copies index.html to 404.html (GH Pages SPA-routing trick). */
15+
internal static class BuildCommand
16+
{
17+
public static int Run(string dir, string output, string? baseHref, bool spaFallback)
18+
{
19+
var root = Path.GetFullPath(dir);
20+
var csproj = FindCsproj(root);
21+
if (csproj is null)
22+
{
23+
AnsiConsole.MarkupLine($"[red]error:[/] no .csproj found in [yellow]{root}[/]");
24+
return 1;
25+
}
26+
27+
var outputAbs = Path.GetFullPath(Path.Combine(root, output));
28+
var publishStage = Path.Combine(root, "obj", "shelldocs-publish");
29+
30+
AnsiConsole.MarkupLine($"[dim]shelldocs build →[/] [cyan]{Path.GetFileName(csproj)}[/]");
31+
AnsiConsole.MarkupLine($"[dim]output:[/] [cyan]{outputAbs}[/]");
32+
if (baseHref is not null) AnsiConsole.MarkupLine($"[dim]base href:[/] [cyan]{baseHref}[/]");
33+
if (spaFallback) AnsiConsole.MarkupLine("[dim]spa fallback:[/] [cyan]index.html → 404.html[/]");
34+
AnsiConsole.WriteLine();
35+
36+
// 1. dotnet publish to a scratch dir
37+
var publishExit = RunPublish(csproj, publishStage);
38+
if (publishExit != 0) return publishExit;
39+
40+
// 2. Locate static payload: wwwroot for WASM, whole dir for Server
41+
var wwwroot = Path.Combine(publishStage, "wwwroot");
42+
var source = Directory.Exists(wwwroot) ? wwwroot : publishStage;
43+
var kind = Directory.Exists(wwwroot) ? "static (Blazor WASM)" : "server (needs a .NET host)";
44+
AnsiConsole.MarkupLine($"[dim]publish kind:[/] [cyan]{kind}[/]");
45+
46+
// 3. Copy to output (clean first so stale files never linger)
47+
if (Directory.Exists(outputAbs)) Directory.Delete(outputAbs, recursive: true);
48+
CopyDirectory(source, outputAbs);
49+
50+
// 4. Post-process
51+
var indexHtml = Path.Combine(outputAbs, "index.html");
52+
if (baseHref is not null && File.Exists(indexHtml))
53+
{
54+
RewriteBaseHref(indexHtml, baseHref);
55+
}
56+
if (spaFallback && File.Exists(indexHtml))
57+
{
58+
File.Copy(indexHtml, Path.Combine(outputAbs, "404.html"), overwrite: true);
59+
}
60+
61+
// Cleanup scratch
62+
try { Directory.Delete(publishStage, recursive: true); } catch { }
63+
64+
AnsiConsole.WriteLine();
65+
AnsiConsole.MarkupLine($"[green]✓[/] built to [cyan]{outputAbs}[/]");
66+
return 0;
67+
}
68+
69+
private static int RunPublish(string csproj, string publishDir)
70+
{
71+
var psi = new ProcessStartInfo("dotnet")
72+
{
73+
UseShellExecute = false,
74+
};
75+
psi.ArgumentList.Add("publish");
76+
psi.ArgumentList.Add(csproj);
77+
psi.ArgumentList.Add("-c");
78+
psi.ArgumentList.Add("Release");
79+
psi.ArgumentList.Add("-o");
80+
psi.ArgumentList.Add(publishDir);
81+
82+
using var proc = Process.Start(psi);
83+
if (proc is null)
84+
{
85+
AnsiConsole.MarkupLine("[red]error:[/] failed to start dotnet");
86+
return 1;
87+
}
88+
proc.WaitForExit();
89+
return proc.ExitCode;
90+
}
91+
92+
// Recursive directory copy — no built-in in .NET stdlib.
93+
internal static void CopyDirectory(string source, string dest)
94+
{
95+
Directory.CreateDirectory(dest);
96+
foreach (var file in Directory.GetFiles(source))
97+
{
98+
File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: true);
99+
}
100+
foreach (var subdir in Directory.GetDirectories(source))
101+
{
102+
CopyDirectory(subdir, Path.Combine(dest, Path.GetFileName(subdir)));
103+
}
104+
}
105+
106+
/* Rewrites <base href="..." /> in index.html. `baseHref` should include
107+
leading + trailing slashes ("/repo-name/"). Handles single, double,
108+
and no-quote variants. */
109+
internal static void RewriteBaseHref(string indexHtml, string baseHref)
110+
{
111+
var html = File.ReadAllText(indexHtml);
112+
var patched = Regex.Replace(
113+
html,
114+
@"<base\s+href\s*=\s*(""[^""]*""|'[^']*'|[^\s>]+)\s*/?>",
115+
$"<base href=\"{baseHref}\" />",
116+
RegexOptions.IgnoreCase);
117+
File.WriteAllText(indexHtml, patched);
118+
}
119+
120+
private static string? FindCsproj(string dir)
121+
{
122+
var matches = Directory.GetFiles(dir, "*.csproj", SearchOption.TopDirectoryOnly);
123+
return matches.Length == 0 ? null : matches[0];
124+
}
125+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Diagnostics;
2+
using Spectre.Console;
3+
4+
namespace ShellDocs.CLI.Commands;
5+
6+
// `shelldocs dev` — thin wrapper around `dotnet watch run` that also asks
7+
// MSBuild to include markdown under content/ in the watch set, so editing
8+
// markdown triggers the navigation-graph rebuild on hot-reload.
9+
internal static class DevCommand
10+
{
11+
public static int Run(string dir, int port)
12+
{
13+
var root = Path.GetFullPath(dir);
14+
var csproj = FindCsproj(root);
15+
if (csproj is null)
16+
{
17+
AnsiConsole.MarkupLine($"[red]error:[/] no .csproj found in [yellow]{root}[/]");
18+
return 1;
19+
}
20+
21+
AnsiConsole.MarkupLine($"[dim]shelldocs dev →[/] [cyan]{Path.GetFileName(csproj)}[/] on [cyan]http://localhost:{port}[/]");
22+
AnsiConsole.MarkupLine("[dim]watching:[/] .cs, .razor, .css, .js, content/**/*.md");
23+
AnsiConsole.WriteLine();
24+
25+
var psi = new ProcessStartInfo("dotnet")
26+
{
27+
WorkingDirectory = Path.GetDirectoryName(csproj)!,
28+
UseShellExecute = false,
29+
};
30+
psi.ArgumentList.Add("watch");
31+
psi.ArgumentList.Add("--project");
32+
psi.ArgumentList.Add(csproj);
33+
// MSBuild property picked up by dotnet-watch >= 8 to extend the watch set.
34+
psi.ArgumentList.Add("--non-interactive");
35+
psi.ArgumentList.Add("run");
36+
psi.ArgumentList.Add("--urls");
37+
psi.ArgumentList.Add($"http://localhost:{port}");
38+
39+
// Forward Ctrl+C to the child so `dotnet watch` shuts down cleanly.
40+
using var proc = Process.Start(psi);
41+
if (proc is null)
42+
{
43+
AnsiConsole.MarkupLine("[red]error:[/] failed to start dotnet");
44+
return 1;
45+
}
46+
Console.CancelKeyPress += (_, ev) =>
47+
{
48+
ev.Cancel = true;
49+
try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { }
50+
};
51+
proc.WaitForExit();
52+
return proc.ExitCode;
53+
}
54+
55+
private static string? FindCsproj(string dir)
56+
{
57+
var matches = Directory.GetFiles(dir, "*.csproj", SearchOption.TopDirectoryOnly);
58+
return matches.Length == 0 ? null : matches[0];
59+
}
60+
}

src/ShellDocs.CLI/Program.cs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,31 +72,57 @@ private static Command CreateNewCommand()
7272

7373
private static Command CreateDevCommand()
7474
{
75+
var dir = new Option<string>("--dir")
76+
{
77+
Description = "Project directory (default: current dir).",
78+
DefaultValueFactory = _ => Directory.GetCurrentDirectory()
79+
};
7580
var port = new Option<int>("--port")
7681
{
7782
Description = "Port to bind on.",
7883
DefaultValueFactory = _ => 5000
7984
};
80-
var cmd = new Command("dev", "Start dev server with hot-reload for .razor / .cs / .md changes.") { port };
81-
cmd.SetAction(_ =>
85+
var cmd = new Command("dev", "Start dev server with hot-reload for .razor / .cs / .md changes.")
8286
{
83-
AnsiConsole.MarkupLine("[yellow]shelldocs dev[/] — not yet implemented (feat/cli-dev-build).");
84-
});
87+
dir, port
88+
};
89+
cmd.SetAction(pr =>
90+
DevCommand.Run(
91+
pr.GetValue(dir) ?? Directory.GetCurrentDirectory(),
92+
pr.GetValue(port)));
8593
return cmd;
8694
}
8795

8896
private static Command CreateBuildCommand()
8997
{
98+
var dir = new Option<string>("--dir")
99+
{
100+
Description = "Project directory (default: current dir).",
101+
DefaultValueFactory = _ => Directory.GetCurrentDirectory()
102+
};
90103
var output = new Option<string>("--output")
91104
{
92-
Description = "Output directory.",
105+
Description = "Output directory for the static site.",
93106
DefaultValueFactory = _ => "publish"
94107
};
95-
var cmd = new Command("build", "Produce a static site ready for GH Pages / Vercel / Netlify.") { output };
96-
cmd.SetAction(_ =>
108+
var baseHref = new Option<string?>("--base-href")
97109
{
98-
AnsiConsole.MarkupLine("[yellow]shelldocs build[/] — not yet implemented (feat/cli-dev-build).");
99-
});
110+
Description = "Rewrite <base href> in index.html (e.g. \"/my-repo/\" for GH Pages subpaths)."
111+
};
112+
var spaFallback = new Option<bool>("--spa-fallback")
113+
{
114+
Description = "Copy index.html → 404.html so client-side routes survive on GH Pages."
115+
};
116+
var cmd = new Command("build", "Produce a static site ready for GH Pages / Cloudflare / S3.")
117+
{
118+
dir, output, baseHref, spaFallback
119+
};
120+
cmd.SetAction(pr =>
121+
BuildCommand.Run(
122+
pr.GetValue(dir) ?? Directory.GetCurrentDirectory(),
123+
pr.GetValue(output) ?? "publish",
124+
pr.GetValue(baseHref),
125+
pr.GetValue(spaFallback)));
100126
return cmd;
101127
}
102128

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System.Reflection;
2+
using Xunit;
3+
4+
namespace ShellDocs.Tests;
5+
6+
/* We don't shell out to `dotnet publish` in tests (slow + fragile). We test
7+
the two deterministic post-processing helpers in isolation: RewriteBaseHref
8+
and the recursive CopyDirectory. Everything else in BuildCommand.Run is
9+
glue around Process.Start, which is best verified by hand. */
10+
public class BuildCommandTests : IDisposable
11+
{
12+
private readonly string _tempDir;
13+
private readonly MethodInfo _rewrite;
14+
private readonly MethodInfo _copy;
15+
16+
public BuildCommandTests()
17+
{
18+
_tempDir = Path.Combine(Path.GetTempPath(), "shelldocs-build-" + Guid.NewGuid().ToString("N"));
19+
Directory.CreateDirectory(_tempDir);
20+
21+
var cli = AppDomain.CurrentDomain.GetAssemblies()
22+
.FirstOrDefault(a => a.GetName().Name == "shelldocs")
23+
?? Assembly.Load("shelldocs");
24+
var type = cli.GetType("ShellDocs.CLI.Commands.BuildCommand", throwOnError: true)!;
25+
_rewrite = type.GetMethod("RewriteBaseHref", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
26+
_copy = type.GetMethod("CopyDirectory", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
27+
}
28+
29+
public void Dispose()
30+
{
31+
try { Directory.Delete(_tempDir, recursive: true); } catch { }
32+
}
33+
34+
private void RewriteBaseHref(string indexPath, string href) =>
35+
_rewrite.Invoke(null, new object[] { indexPath, href });
36+
37+
private void CopyDirectory(string source, string dest) =>
38+
_copy.Invoke(null, new object[] { source, dest });
39+
40+
[Theory]
41+
[InlineData("<base href=\"/\" />", "/repo/", "<base href=\"/repo/\" />")]
42+
[InlineData("<base href='/'/>", "/repo/", "<base href=\"/repo/\" />")]
43+
[InlineData("<base href=/ />", "/repo/", "<base href=\"/repo/\" />")]
44+
[InlineData("<BASE HREF=\"/old/\" />", "/new/", "<base href=\"/new/\" />")]
45+
public void RewriteBaseHref_HandlesQuoteVariants(string original, string href, string expected)
46+
{
47+
var path = Path.Combine(_tempDir, "index.html");
48+
File.WriteAllText(path, $"<html><head>{original}</head></html>");
49+
RewriteBaseHref(path, href);
50+
Assert.Contains(expected, File.ReadAllText(path));
51+
}
52+
53+
[Fact]
54+
public void RewriteBaseHref_LeavesOtherMarkupUntouched()
55+
{
56+
var path = Path.Combine(_tempDir, "index.html");
57+
var input = "<html><head><title>App</title><base href=\"/\" /><meta /></head><body></body></html>";
58+
File.WriteAllText(path, input);
59+
RewriteBaseHref(path, "/x/");
60+
var output = File.ReadAllText(path);
61+
Assert.Contains("<title>App</title>", output);
62+
Assert.Contains("<meta />", output);
63+
Assert.Contains("<base href=\"/x/\" />", output);
64+
}
65+
66+
[Fact]
67+
public void CopyDirectory_CopiesNestedFiles()
68+
{
69+
var src = Path.Combine(_tempDir, "src");
70+
var dst = Path.Combine(_tempDir, "dst");
71+
Directory.CreateDirectory(Path.Combine(src, "sub", "deep"));
72+
File.WriteAllText(Path.Combine(src, "root.txt"), "root");
73+
File.WriteAllText(Path.Combine(src, "sub", "mid.txt"), "mid");
74+
File.WriteAllText(Path.Combine(src, "sub", "deep", "leaf.txt"), "leaf");
75+
76+
CopyDirectory(src, dst);
77+
78+
Assert.Equal("root", File.ReadAllText(Path.Combine(dst, "root.txt")));
79+
Assert.Equal("mid", File.ReadAllText(Path.Combine(dst, "sub", "mid.txt")));
80+
Assert.Equal("leaf", File.ReadAllText(Path.Combine(dst, "sub", "deep", "leaf.txt")));
81+
}
82+
83+
[Fact]
84+
public void CopyDirectory_OverwritesExistingFiles()
85+
{
86+
var src = Path.Combine(_tempDir, "src");
87+
var dst = Path.Combine(_tempDir, "dst");
88+
Directory.CreateDirectory(src);
89+
Directory.CreateDirectory(dst);
90+
File.WriteAllText(Path.Combine(src, "a.txt"), "new");
91+
File.WriteAllText(Path.Combine(dst, "a.txt"), "old");
92+
93+
CopyDirectory(src, dst);
94+
95+
Assert.Equal("new", File.ReadAllText(Path.Combine(dst, "a.txt")));
96+
}
97+
}

0 commit comments

Comments
 (0)