Skip to content

Commit c4246db

Browse files
committed
feat(build): --site-url emits sitemap.xml, robots.txt, and og meta
1 parent a3fdd5c commit c4246db

4 files changed

Lines changed: 230 additions & 8 deletions

File tree

src/ShellDocs.CLI/Commands/BuildCommand.cs

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace ShellDocs.CLI.Commands;
77

88
internal static class BuildCommand
99
{
10-
public static int Run(string dir, string output, string? baseHref, bool spaFallback)
10+
public static int Run(string dir, string output, string? baseHref, bool spaFallback, string? siteUrl = null)
1111
{
1212
var root = Path.GetFullPath(dir);
1313
var csproj = FindCsproj(root);
@@ -27,11 +27,13 @@ public static int Run(string dir, string output, string? baseHref, bool spaFallb
2727

2828
var outputAbs = Path.GetFullPath(Path.Combine(root, output));
2929
var publishStage = Path.Combine(root, "obj", "shelldocs-publish");
30+
var normalizedSiteUrl = siteUrl?.TrimEnd('/');
3031

3132
AnsiConsole.MarkupLine($"[dim]shelldocs build →[/] [cyan]{Path.GetFileName(csproj)}[/]");
3233
AnsiConsole.MarkupLine($"[dim]output:[/] [cyan]{outputAbs}[/]");
33-
if (baseHref is not null) AnsiConsole.MarkupLine($"[dim]base href:[/] [cyan]{baseHref}[/]");
34-
if (spaFallback) AnsiConsole.MarkupLine("[dim]spa fallback:[/] [cyan]index.html → 404.html[/]");
34+
if (baseHref is not null) AnsiConsole.MarkupLine($"[dim]base href:[/] [cyan]{baseHref}[/]");
35+
if (spaFallback) AnsiConsole.MarkupLine("[dim]spa fallback:[/] [cyan]index.html → 404.html[/]");
36+
if (normalizedSiteUrl is not null) AnsiConsole.MarkupLine($"[dim]site url:[/] [cyan]{normalizedSiteUrl}[/]");
3537
AnsiConsole.WriteLine();
3638

3739
var publishExit = RunPublish(csproj, publishStage);
@@ -51,9 +53,10 @@ public static int Run(string dir, string output, string? baseHref, bool spaFallb
5153

5254
// Home ("/") is served by Home.razor and isn't part of NavigationGraph.
5355
var urls = new List<string> { "/" };
56+
NavigationGraph? graph = null;
5457
try
5558
{
56-
var graph = NavigationGraphBuilder.Build(contentRoot);
59+
graph = NavigationGraphBuilder.Build(contentRoot);
5760
urls.AddRange(graph.AllUrls);
5861
}
5962
catch (Exception ex)
@@ -99,6 +102,14 @@ public static int Run(string dir, string output, string? baseHref, bool spaFallb
99102
}
100103
}
101104

105+
if (normalizedSiteUrl is not null && graph is not null)
106+
{
107+
WriteSitemap(outputAbs, normalizedSiteUrl, urls);
108+
WriteRobots(outputAbs, normalizedSiteUrl);
109+
var ogCount = InjectOgMeta(outputAbs, normalizedSiteUrl, graph);
110+
AnsiConsole.MarkupLine($"[dim]seo:[/] sitemap.xml + robots.txt + og meta on [cyan]{ogCount}[/] page(s)");
111+
}
112+
102113
try { Directory.Delete(publishStage, recursive: true); } catch { }
103114

104115
AnsiConsole.WriteLine();
@@ -181,4 +192,69 @@ internal static int RewriteBaseHrefInAllHtml(string outputDir, string baseHref)
181192
var matches = Directory.GetFiles(dir, "*.csproj", SearchOption.TopDirectoryOnly);
182193
return matches.Length == 0 ? null : matches[0];
183194
}
195+
196+
internal static void WriteSitemap(string outputDir, string siteUrl, IReadOnlyList<string> urls)
197+
{
198+
var sb = new System.Text.StringBuilder();
199+
sb.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
200+
sb.AppendLine("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">");
201+
foreach (var url in urls.Distinct(StringComparer.OrdinalIgnoreCase))
202+
{
203+
var abs = siteUrl + (url.StartsWith('/') ? url : "/" + url);
204+
sb.Append(" <url><loc>").Append(System.Net.WebUtility.HtmlEncode(abs)).AppendLine("</loc></url>");
205+
}
206+
sb.AppendLine("</urlset>");
207+
File.WriteAllText(Path.Combine(outputDir, "sitemap.xml"), sb.ToString());
208+
}
209+
210+
internal static void WriteRobots(string outputDir, string siteUrl)
211+
{
212+
var body = $"User-agent: *{Environment.NewLine}Allow: /{Environment.NewLine}Sitemap: {siteUrl}/sitemap.xml{Environment.NewLine}";
213+
File.WriteAllText(Path.Combine(outputDir, "robots.txt"), body);
214+
}
215+
216+
// Injects og:title / og:description / og:url / og:type into each prerendered
217+
// HTML file's <head>, using titles + descriptions from the nav graph. Skips
218+
// pages the graph doesn't know about (e.g. root "/" home page).
219+
internal static int InjectOgMeta(string outputDir, string siteUrl, NavigationGraph graph)
220+
{
221+
var count = 0;
222+
foreach (var url in graph.AllUrls)
223+
{
224+
var node = graph.ResolveByUrl(url);
225+
if (node is null) continue;
226+
var htmlPath = UrlToHtmlPath(outputDir, url);
227+
if (!File.Exists(htmlPath)) continue;
228+
229+
var html = File.ReadAllText(htmlPath);
230+
var absUrl = siteUrl + (url.StartsWith('/') ? url : "/" + url);
231+
var meta = BuildOgBlock(node.Title, node.Description, absUrl);
232+
233+
var headClose = html.IndexOf("</head>", StringComparison.OrdinalIgnoreCase);
234+
if (headClose < 0) continue;
235+
var patched = html.Insert(headClose, meta);
236+
File.WriteAllText(htmlPath, patched);
237+
count++;
238+
}
239+
return count;
240+
}
241+
242+
private static string BuildOgBlock(string? title, string? description, string absUrl)
243+
{
244+
var sb = new System.Text.StringBuilder();
245+
sb.Append(" <meta property=\"og:type\" content=\"article\" />").Append(Environment.NewLine);
246+
sb.Append(" <meta property=\"og:url\" content=\"").Append(System.Net.WebUtility.HtmlEncode(absUrl)).Append("\" />").Append(Environment.NewLine);
247+
if (!string.IsNullOrWhiteSpace(title))
248+
sb.Append(" <meta property=\"og:title\" content=\"").Append(System.Net.WebUtility.HtmlEncode(title)).Append("\" />").Append(Environment.NewLine);
249+
if (!string.IsNullOrWhiteSpace(description))
250+
sb.Append(" <meta property=\"og:description\" content=\"").Append(System.Net.WebUtility.HtmlEncode(description)).Append("\" />").Append(Environment.NewLine);
251+
return sb.ToString();
252+
}
253+
254+
private static string UrlToHtmlPath(string outputDir, string url)
255+
{
256+
var trimmed = url.Trim('/');
257+
if (string.IsNullOrEmpty(trimmed)) return Path.Combine(outputDir, "index.html");
258+
return Path.Combine(outputDir, Path.Combine(trimmed.Split('/')), "index.html");
259+
}
184260
}

src/ShellDocs.CLI/Program.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,21 @@ private static Command CreateBuildCommand()
131131
{
132132
Description = "Copy index.html → 404.html so client-side routes survive on GH Pages."
133133
};
134+
var siteUrl = new Option<string?>("--site-url")
135+
{
136+
Description = "Absolute site URL (e.g. \"https://shelldocs.dev\"). Enables sitemap.xml, robots.txt, and og: meta tags."
137+
};
134138
var cmd = new Command("build", "Produce a static site ready for GH Pages / Cloudflare / S3.")
135139
{
136-
dir, output, baseHref, spaFallback
140+
dir, output, baseHref, spaFallback, siteUrl
137141
};
138142
cmd.SetAction(pr =>
139143
BuildCommand.Run(
140144
pr.GetValue(dir) ?? Directory.GetCurrentDirectory(),
141145
pr.GetValue(output) ?? "publish",
142146
pr.GetValue(baseHref),
143-
pr.GetValue(spaFallback)));
147+
pr.GetValue(spaFallback),
148+
pr.GetValue(siteUrl)));
144149
return cmd;
145150
}
146151

src/ShellDocs.Components/ShellDocsOptions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ public class ShellDocsOptions
1010
public string SiteName { get; set; } = "";
1111
public string? SiteTagline { get; set; }
1212
public string? GitHubRepo { get; set; }
13+
// Absolute base URL, e.g. "https://shelldocs.dev". Consumed by
14+
// `shelldocs build` to emit sitemap.xml, robots.txt, and og:url meta.
15+
// Skip those artifacts silently when unset.
16+
public string? SiteUrl { get; set; }
1317

1418
public string? LogoLight { get; set; }
1519
public string? LogoDark { get; set; }

tests/ShellDocs.Tests/BuildCommandTests.cs

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Reflection;
2+
using ShellDocs.Core;
23
using Xunit;
34

45
namespace ShellDocs.Tests;
@@ -8,6 +9,9 @@ public class BuildCommandTests : IDisposable
89
private readonly string _tempDir;
910
private readonly MethodInfo _rewrite;
1011
private readonly MethodInfo _copy;
12+
private readonly MethodInfo _writeSitemap;
13+
private readonly MethodInfo _writeRobots;
14+
private readonly MethodInfo _injectOg;
1115

1216
public BuildCommandTests()
1317
{
@@ -18,8 +22,11 @@ public BuildCommandTests()
1822
.FirstOrDefault(a => a.GetName().Name == "shelldocs")
1923
?? Assembly.Load("shelldocs");
2024
var type = cli.GetType("ShellDocs.CLI.Commands.BuildCommand", throwOnError: true)!;
21-
_rewrite = type.GetMethod("RewriteBaseHrefInAllHtml", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
22-
_copy = type.GetMethod("CopyDirectoryMerging", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
25+
_rewrite = type.GetMethod("RewriteBaseHrefInAllHtml", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
26+
_copy = type.GetMethod("CopyDirectoryMerging", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
27+
_writeSitemap = type.GetMethod("WriteSitemap", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
28+
_writeRobots = type.GetMethod("WriteRobots", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
29+
_injectOg = type.GetMethod("InjectOgMeta", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!;
2330
}
2431

2532
public void Dispose()
@@ -33,6 +40,26 @@ private int RewriteBaseHrefInAllHtml(string outputDir, string href) =>
3340
private void CopyDirectoryMerging(string source, string dest) =>
3441
_copy.Invoke(null, new object[] { source, dest });
3542

43+
private void WriteSitemap(string outputDir, string siteUrl, IReadOnlyList<string> urls) =>
44+
_writeSitemap.Invoke(null, new object[] { outputDir, siteUrl, urls });
45+
46+
private void WriteRobots(string outputDir, string siteUrl) =>
47+
_writeRobots.Invoke(null, new object[] { outputDir, siteUrl });
48+
49+
private int InjectOgMeta(string outputDir, string siteUrl, NavigationGraph graph) =>
50+
(int)_injectOg.Invoke(null, new object[] { outputDir, siteUrl, graph })!;
51+
52+
// NavigationNode.Children/Parent have `internal set` — bypass via reflection
53+
// so tests can build a graph without exposing the setters or standing up a
54+
// temp content directory.
55+
private static readonly PropertyInfo _childrenProp = typeof(NavigationNode).GetProperty("Children")!;
56+
private static readonly PropertyInfo _parentProp = typeof(NavigationNode).GetProperty("Parent")!;
57+
private static void LinkChildren(NavigationNode parent, params NavigationNode[] children)
58+
{
59+
_childrenProp.SetValue(parent, children);
60+
foreach (var c in children) _parentProp.SetValue(c, parent);
61+
}
62+
3663
[Theory]
3764
[InlineData("<base href=\"/\" />", "/repo/", "<base href=\"/repo/\" />")]
3865
[InlineData("<base href='/'/>", "/repo/", "<base href=\"/repo/\" />")]
@@ -129,4 +156,114 @@ public void CopyDirectoryMerging_CopiesMissingFilesEvenWhenSomeExist()
129156
Assert.Equal("dst-a", File.ReadAllText(Path.Combine(dst, "a.txt")));
130157
Assert.Equal("src-b", File.ReadAllText(Path.Combine(dst, "b.txt")));
131158
}
159+
160+
[Fact]
161+
public void WriteSitemap_ProducesValidUrlSet()
162+
{
163+
WriteSitemap(_tempDir, "https://example.com", new[] { "/", "/docs/introduction", "/docs/cli/build" });
164+
165+
var xml = File.ReadAllText(Path.Combine(_tempDir, "sitemap.xml"));
166+
Assert.Contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>", xml);
167+
Assert.Contains("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">", xml);
168+
Assert.Contains("<loc>https://example.com/</loc>", xml);
169+
Assert.Contains("<loc>https://example.com/docs/introduction</loc>", xml);
170+
Assert.Contains("<loc>https://example.com/docs/cli/build</loc>", xml);
171+
Assert.Contains("</urlset>", xml);
172+
}
173+
174+
[Fact]
175+
public void WriteSitemap_DeduplicatesUrls()
176+
{
177+
WriteSitemap(_tempDir, "https://example.com", new[] { "/", "/", "/docs/x", "/docs/x" });
178+
179+
var xml = File.ReadAllText(Path.Combine(_tempDir, "sitemap.xml"));
180+
Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(xml, "<url>").Count);
181+
}
182+
183+
[Fact]
184+
public void WriteRobots_IncludesSitemapReference()
185+
{
186+
WriteRobots(_tempDir, "https://example.com");
187+
var txt = File.ReadAllText(Path.Combine(_tempDir, "robots.txt"));
188+
189+
Assert.Contains("User-agent: *", txt);
190+
Assert.Contains("Allow: /", txt);
191+
Assert.Contains("Sitemap: https://example.com/sitemap.xml", txt);
192+
}
193+
194+
[Fact]
195+
public void InjectOgMeta_AddsOgTagsBeforeHeadClose()
196+
{
197+
var pagePath = Path.Combine(_tempDir, "docs", "intro", "index.html");
198+
Directory.CreateDirectory(Path.GetDirectoryName(pagePath)!);
199+
File.WriteAllText(pagePath, "<html><head><title>t</title></head><body>b</body></html>");
200+
201+
var pageNode = new NavigationNode
202+
{
203+
Url = "/docs/intro",
204+
Title = "Introduction",
205+
Description = "What ShellDocs is.",
206+
Kind = NodeKind.Page,
207+
};
208+
var root = new NavigationNode { Url = "/", Kind = NodeKind.Section };
209+
LinkChildren(root, pageNode);
210+
var graph = new NavigationGraph(root);
211+
212+
var count = InjectOgMeta(_tempDir, "https://example.com", graph);
213+
214+
Assert.Equal(1, count);
215+
var html = File.ReadAllText(pagePath);
216+
Assert.Contains("<meta property=\"og:type\" content=\"article\" />", html);
217+
Assert.Contains("<meta property=\"og:url\" content=\"https://example.com/docs/intro\" />", html);
218+
Assert.Contains("<meta property=\"og:title\" content=\"Introduction\" />", html);
219+
Assert.Contains("<meta property=\"og:description\" content=\"What ShellDocs is.\" />", html);
220+
// Injected before </head>, not after.
221+
var ogIdx = html.IndexOf("og:type", StringComparison.Ordinal);
222+
var closeIdx = html.IndexOf("</head>", StringComparison.Ordinal);
223+
Assert.True(ogIdx > 0 && ogIdx < closeIdx, "og:type meta must appear before </head>");
224+
}
225+
226+
[Fact]
227+
public void InjectOgMeta_SkipsMissingHtmlFilesGracefully()
228+
{
229+
var pageNode = new NavigationNode
230+
{
231+
Url = "/nowhere",
232+
Title = "Ghost",
233+
Description = "Not on disk.",
234+
Kind = NodeKind.Page,
235+
};
236+
var root = new NavigationNode { Url = "/", Kind = NodeKind.Section };
237+
LinkChildren(root, pageNode);
238+
var graph = new NavigationGraph(root);
239+
240+
var count = InjectOgMeta(_tempDir, "https://example.com", graph);
241+
242+
Assert.Equal(0, count);
243+
}
244+
245+
[Fact]
246+
public void InjectOgMeta_EncodesSpecialCharacters()
247+
{
248+
var pagePath = Path.Combine(_tempDir, "p", "index.html");
249+
Directory.CreateDirectory(Path.GetDirectoryName(pagePath)!);
250+
File.WriteAllText(pagePath, "<html><head></head></html>");
251+
252+
var pageNode = new NavigationNode
253+
{
254+
Url = "/p",
255+
Title = "AT&T <spec>",
256+
Description = "Uses & and <",
257+
Kind = NodeKind.Page,
258+
};
259+
var root = new NavigationNode { Url = "/", Kind = NodeKind.Section };
260+
LinkChildren(root, pageNode);
261+
var graph = new NavigationGraph(root);
262+
263+
InjectOgMeta(_tempDir, "https://example.com", graph);
264+
265+
var html = File.ReadAllText(pagePath);
266+
Assert.Contains("AT&amp;T &lt;spec&gt;", html);
267+
Assert.Contains("Uses &amp; and &lt;", html);
268+
}
132269
}

0 commit comments

Comments
 (0)