using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
namespace GitCover.WebStaticBuilder;
/// Main build orchestrator.
public static class Builder
{
/// true if the build completed; false if it was
/// aborted due to a pre-flight check failure (missing content directory).
public static bool Build(BuildConfig cfg, EnvironmentConfig env, string envName, bool verbose, bool dryRun)
{
// Pre-flight: content directory must exist — otherwise ContentLoader
// throws an unhandled DirectoryNotFoundException. Fail gracefully with
// an actionable message instead of a stack trace.
if (!Directory.Exists(Directories.ContentDir))
{
Console.Error.WriteLine(
$"ERROR: content directory not found: {Directories.ContentDir}");
Console.Error.WriteLine(
" Specify --root
or --content .");
return false;
}
File.WriteAllText(Directories.BuildLogPath,
$"=== Build Started: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} ===\n");
File.AppendAllText(Directories.BuildLogPath,
$"ENV: {envName}\nBASE_URL: {env.BaseUrl}\nCDN_ASSETS: {env.Cdn}\nROOT: {Directories.Root}\n\n");
File.WriteAllText(Directories.RunLogPath,
$"\n=== Run Started: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} ===\n");
if (verbose)
{
Console.WriteLine("Build configuration:");
Console.WriteLine($" ENV: {envName}");
Console.WriteLine($" BASE_URL: {env.BaseUrl}");
Console.WriteLine($" CDN_ASSETS: {env.Cdn}");
Console.WriteLine($" Content: {Directories.ContentDir}");
Console.WriteLine($" Templates: {Directories.TemplateDir}");
Console.WriteLine($" Output: {Directories.DistDir}");
}
// Load UI translations (best-effort; templates fall back to keys if absent)
I18n.Load(Directories.I18nFile);
if (verbose && I18n.Loaded) Console.WriteLine($" i18n: loaded {Directories.I18nFile}");
string layout = LoadLayout();
Directory.CreateDirectory(Directories.DistDir);
CopyAssets(verbose);
// Precompute, for every page, its slug per language (keyed by order|section),
// so the language switcher can link to the equivalent page in each language
// even when slugs differ between translations.
var pageSlugs = new Dictionary>();
foreach (var l in cfg.LanguageCodes())
{
foreach (var p in ContentLoader.LoadContent(Directories.ContentDir, l))
{
var key = $"{p.Meta.Order ?? -1}|{p.Meta.Section ?? ""}";
if (!pageSlugs.TryGetValue(key, out var m))
pageSlugs[key] = m = new Dictionary();
m[l] = p.Href;
}
}
foreach (var lang in cfg.LanguageCodes())
{
try
{
var contents = ContentLoader.LoadContent(Directories.ContentDir, lang);
if (verbose) Console.WriteLine($"Rendering {contents.Count} content files for language {lang}");
File.AppendAllText(Directories.RunLogPath, $"Language: {lang}, Files: {contents.Count}\n");
var sectionsHtml = BuildSectionsHtml(contents, lang);
var navLinksHtml = BuildNavLinksHtml(contents, lang);
var sidebarNavHtml = BuildSidebarNavHtml(contents, lang);
var bannerPage = contents.FirstOrDefault(p => p.Meta.Order == 0);
var bannerHtml = "";
if (bannerPage != null)
{
bannerHtml = MarkdownProcessor.RenderMarkdown(bannerPage.Content);
bannerHtml = MarkdownProcessor.MermaidPostprocess(bannerHtml);
bannerHtml = MarkdownProcessor.TablePostprocess(bannerHtml);
}
foreach (var page in contents)
{
if (!page.Meta.Render) continue;
if (page.Meta.Order == 0) continue;
var title = page.Meta.LocalizedTitle(lang, page.Meta.Title ?? Path.GetFileNameWithoutExtension(page.Path));
var desc = page.Meta.LocalizedDescription(lang, "");
if (string.IsNullOrEmpty(title))
title = Path.GetFileNameWithoutExtension(page.Path);
var htmlContent = MarkdownProcessor.RenderMarkdown(page.Content);
htmlContent = MarkdownProcessor.MermaidPostprocess(htmlContent);
htmlContent = MarkdownProcessor.TablePostprocess(htmlContent);
var fullBody = page.Meta.Order == 1
? bannerHtml + htmlContent
: htmlContent;
var slug = page.Href.Trim('/');
var canonical = string.IsNullOrEmpty(slug)
? $"/{lang}/"
: $"/{lang}/{slug}.html";
var defaultTitle = cfg.Site.Title.TryGetValue(lang, out var dt) ? dt : cfg.Site.Name;
var defaultDesc = cfg.Site.Description.TryGetValue(lang, out var dd) ? dd : "";
var seoHead = SeoHelper.RenderSeoHead(lang, title, desc, $"{env.BaseUrl}{canonical}",
env.Cdn, defaultTitle, defaultDesc);
var sidebarTocHtml = BuildSidebarTocHtml(page.Content);
var pageNavHtml = (page.Meta.Order == 1) ? "" : BuildPageNavHtml(contents, page, lang);
var introHtml = string.IsNullOrEmpty(page.Meta.Intro)
? "" : MarkdownProcessor.RenderMarkdown(page.Meta.Intro);
var positioningHtml = string.IsNullOrEmpty(page.Meta.Positioning)
? "" : MarkdownProcessor.RenderMarkdown(page.Meta.Positioning);
var publisherHtml = string.IsNullOrEmpty(page.Meta.Publisher)
? "" : MarkdownProcessor.RenderMarkdown(page.Meta.Publisher);
var langKey = $"{page.Meta.Order ?? -1}|{page.Meta.Section ?? ""}";
var langLinksHtml = BuildLangLinksHtml(
pageSlugs.TryGetValue(langKey, out var m) ? m : null);
var pageHtml = layout
.Replace("{{seo_head}}", seoHead).Replace("{{ seo_head }}", seoHead)
.Replace("{{ json_ld }}", "").Replace("{{json_ld}}", "")
.Replace("{{sections}}", sectionsHtml).Replace("{{ sections }}", sectionsHtml)
.Replace("{{nav_links}}", navLinksHtml).Replace("{{ nav_links }}", navLinksHtml)
.Replace("{{sidebar_nav}}", sidebarNavHtml).Replace("{{ sidebar_nav }}", sidebarNavHtml)
.Replace("{{sidebar_toc}}", sidebarTocHtml).Replace("{{ sidebar_toc }}", sidebarTocHtml)
.Replace("{{page_nav}}", pageNavHtml).Replace("{{ page_nav }}", pageNavHtml)
.Replace("{{lang}}", lang).Replace("{{ lang }}", lang)
.Replace("{{lang_links}}", langLinksHtml).Replace("{{ lang_links }}", langLinksHtml)
.Replace("{{glossary_href}}", GlossaryHref(lang)).Replace("{{ glossary_href }}", GlossaryHref(lang))
.Replace("{{glossary_label}}", GlossaryLabel(lang)).Replace("{{ glossary_label }}", GlossaryLabel(lang))
.Replace("{{intro}}", introHtml).Replace("{{ intro }}", introHtml)
.Replace("{{positioning}}", positioningHtml).Replace("{{ positioning }}", positioningHtml)
.Replace("{{publisher}}", publisherHtml).Replace("{{ publisher }}", publisherHtml)
.Replace("{{content}}", fullBody).Replace("{{ content }}", fullBody)
.Replace("{{body}}", fullBody).Replace("{{ body }}", fullBody);
pageHtml = I18n.Substitute(pageHtml, lang);
if (dryRun)
{
if (verbose) Console.WriteLine($" [dry-run] Would write {lang}/{page.Href}");
continue;
}
var ldir = Path.Combine(Directories.DistDir, lang);
Directory.CreateDirectory(ldir);
var fileSlug = page.Href.Trim('/');
var outputFile = string.IsNullOrEmpty(fileSlug) ? "index.html" : $"{fileSlug}.html";
var outputPath = Path.Combine(ldir, outputFile);
File.WriteAllText(outputPath, pageHtml);
if (page.Meta.Order == 1)
{
var heroIndexPath = Path.Combine(ldir, "index.html");
File.WriteAllText(heroIndexPath, pageHtml);
if (verbose) Console.WriteLine($" Written {heroIndexPath} (hero landing)");
File.AppendAllText(Directories.BuildLogPath, $" Written: {heroIndexPath} (hero landing)\n");
}
if (verbose) Console.WriteLine($" Written {outputPath}");
File.AppendAllText(Directories.BuildLogPath, $" Written: {outputPath}\n");
}
}
catch (Exception ex)
{
Console.WriteLine($"ERROR rendering {lang}: {ex.Message}");
File.AppendAllText(Directories.BuildLogPath, $"ERROR rendering {lang}: {ex.Message}\n{ex.StackTrace}\n\n");
File.AppendAllText(Directories.RunLogPath, $"ERROR rendering {lang}: {ex.Message}\n{ex.StackTrace}\n\n");
}
}
BuildLegalPages(cfg, env, verbose);
File.WriteAllText(Path.Combine(Directories.DistDir, "index.html"), BuildRootIndex(cfg, env.BaseUrl));
if (verbose) Console.WriteLine(" Written root index.html");
File.WriteAllText(Path.Combine(Directories.DistDir, "robots.txt"),
$"User-agent: *\nAllow: /\nSitemap: {env.BaseUrl}/sitemap.xml");
BuildSitemap(cfg, env.BaseUrl, verbose);
if (cfg.Pagefind) BuildSearchIndex(verbose);
else if (verbose) Console.WriteLine(" Pagefind disabled (config).");
return true;
}
private static string LoadLayout()
{
var layoutPath = Path.Combine(Directories.TemplateDir, "layout.html");
if (File.Exists(layoutPath)) return File.ReadAllText(layoutPath);
Console.WriteLine($"WARNING: Layout file not found: {layoutPath}");
return "{{body}}";
}
private static void CopyAssets(bool verbose)
{
var srcDir = Directories.AssetsDir;
var dstDir = Path.Combine(Directories.DistDir, "assets");
if (!Directory.Exists(srcDir))
{
Console.WriteLine($"WARNING: Assets directory not found: {srcDir}");
return;
}
if (Directory.Exists(dstDir)) Directory.Delete(dstDir, recursive: true);
Directory.CreateDirectory(dstDir);
foreach (var file in Directory.GetFiles(srcDir, "*", SearchOption.AllDirectories))
{
var rel = Path.GetRelativePath(srcDir, file);
var target = Path.Combine(dstDir, rel);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Copy(file, target, overwrite: true);
}
if (verbose)
{
var count = Directory.GetFiles(dstDir, "*", SearchOption.AllDirectories).Length;
Console.WriteLine($" Copied {count} asset files to {dstDir}");
}
File.AppendAllText(Directories.BuildLogPath, $" Assets copied to: {dstDir}\n");
}
///
/// Build the language-switch dropdown for the current page.
/// maps each language code to the current page's slug in that language (resolved by
/// shared identity — order + section — so each language links to its own equivalent
/// page). When null, all links point to the language root.
///
private static string BuildLangLinksHtml(Dictionary? langSlug)
{
var sb = new StringBuilder();
foreach (var kv in Directories.Config.Languages)
{
var lang = kv.Key;
var li = kv.Value;
var label = li.Label ?? li.Name;
var code = (li.HrefLang ?? lang).ToUpperInvariant();
string slug = "";
if (langSlug != null && langSlug.TryGetValue(lang, out var h)) slug = h.Trim('/');
var href = string.IsNullOrEmpty(slug) ? $"/{lang}/" : $"/{lang}/{slug}.html";
sb.AppendLine($"