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($"
  • {HtmlEncode(label)} {code}
  • "); } return sb.ToString().Trim(); } private static string BuildNavLinksHtml(List contents, string lang) { var sb = new StringBuilder(); foreach (var page in contents) { if (!page.Meta.Navbar) continue; if (page.Meta.Order == 0 || page.Meta.Order == 1) continue; var title = page.Meta.LocalizedTitle(lang, Path.GetFileNameWithoutExtension(page.Path)); var navLabel = page.Meta.Eyebrow ?? title; if (navLabel.Contains(" — ")) navLabel = navLabel.Substring(navLabel.IndexOf(" — ") + 3); var slug = page.Href.Trim('/'); var href = string.IsNullOrEmpty(slug) ? "index.html" : $"{slug}.html"; sb.AppendLine($" {HtmlEncode(navLabel)}"); } return sb.ToString().Trim(); } private static string BuildSidebarNavHtml(List contents, string lang) { var sb = new StringBuilder(); var navPages = contents.Where(p => p.Meta.Navbar && p.Meta.Order != 0 && p.Meta.Order != 1) .OrderBy(p => p.Meta.Order ?? 999).ToList(); var heroPage = contents.FirstOrDefault(p => p.Meta.Order == 1); if (heroPage != null) sb.AppendLine($"
  • {I18n.Get(lang, "nav.home")}
  • "); foreach (var page in navPages) { var navLabel = page.Meta.Eyebrow ?? page.Meta.LocalizedTitle(lang, page.Meta.Title ?? ""); if (navLabel.Contains(" — ")) navLabel = navLabel.Substring(navLabel.IndexOf(" — ") + 3); var slug = page.Href.Trim('/'); var href = string.IsNullOrEmpty(slug) ? "index.html" : $"{slug}.html"; var idx = page.Meta.Order?.ToString() ?? "·"; sb.AppendLine($"
  • {idx} {HtmlEncode(navLabel)}
  • "); } return sb.ToString().Trim(); } private static string BuildSidebarTocHtml(string markdownContent) { var sb = new StringBuilder(); var lines = markdownContent.Split('\n'); var inCodeBlock = false; foreach (var line in lines) { if (line.TrimStart().StartsWith("```")) { inCodeBlock = !inCodeBlock; continue; } if (inCodeBlock) continue; var trimmed = line.TrimStart(); if (trimmed.StartsWith("## ") && !trimmed.StartsWith("### ")) { var headingText = trimmed.Substring(3).Trim(); headingText = Regex.Replace(headingText, @"\*\*(.+?)\*\*", "$1"); headingText = Regex.Replace(headingText, @"\*(.+?)\*", "$1"); headingText = Regex.Replace(headingText, @"`(.+?)`", "$1"); var slug = Regex.Replace(headingText.ToLower(), @"[^a-z0-9äöüß\-]+", "-").Trim('-').TrimEnd(':'); if (!string.IsNullOrEmpty(slug)) sb.AppendLine($"
  • {HtmlEncode(headingText)}
  • "); } } return sb.ToString().Trim(); } private static string BuildPageNavHtml(List contents, PageInfo currentPage, string lang) { var navPages = contents.Where(p => p.Meta.Navbar && p.Meta.Order != 0 && p.Meta.Order != 1) .OrderBy(p => p.Meta.Order ?? 999).ToList(); var currentIndex = navPages.FindIndex(p => p.Path == currentPage.Path); if (currentIndex < 0) return ""; var sb = new StringBuilder(); sb.AppendLine(""); return sb.ToString().Trim(); } private static string BuildSectionsHtml(List contents, string lang) { var sb = new StringBuilder(); foreach (var page in contents) { if (!page.Meta.Navbar) continue; var title = page.Meta.LocalizedTitle(lang, Path.GetFileNameWithoutExtension(page.Path)); sb.AppendLine($" {HtmlEncode(title)}"); } return sb.ToString().Trim(); } private static void BuildLegalPages(BuildConfig cfg, EnvironmentConfig env, bool verbose) { var legalDir = Directories.LegalDir; if (!Directory.Exists(legalDir)) return; var layout = LoadLayout(); foreach (var lang in cfg.LanguageCodes()) { var lld = Path.Combine(Directories.DistDir, lang); Directory.CreateDirectory(lld); var contents = ContentLoader.LoadContent(Directories.ContentDir, lang); var navLinksHtml = BuildNavLinksHtml(contents, lang); var sidebarNavHtml = BuildSidebarNavHtml(contents, lang); try { foreach (var lf in Directory.GetFiles(legalDir, "*.md")) { var bn = Path.GetFileName(lf); if (bn.Contains(".")) { var match = Regex.Match(bn, @".+\.(de|en|vi)\.md$"); if (match.Success && match.Groups[1].Value != lang) continue; } var text = File.ReadAllText(lf); var (meta, content) = ContentLoader.ParseFrontmatter(text); var title = meta.LocalizedTitle(lang, Path.GetFileNameWithoutExtension(bn)); var htmlContent = MarkdownProcessor.RenderMarkdown(content); htmlContent = MarkdownProcessor.MermaidPostprocess(htmlContent); htmlContent = MarkdownProcessor.TablePostprocess(htmlContent); var baseName = Regex.Replace(bn, @"\.(de|en|vi)\.md$", ".html").Replace(".md", ".html"); var legalFileName = Path.GetFileNameWithoutExtension(baseName); var canonical = $"/{lang}/{legalFileName}.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, "", $"{env.BaseUrl}{canonical}", env.Cdn, defaultTitle, defaultDesc); var sidebarTocHtml = BuildSidebarTocHtml(content); var legalSlug = $"{legalFileName}.html"; var legalLangSlug = cfg.LanguageCodes().ToDictionary(l => l, l => $"{l}/{legalFileName}.html"); var langLinksHtml = BuildLangLinksHtml(legalLangSlug); var pageHtml = layout .Replace("{{seo_head}}", seoHead).Replace("{{ seo_head }}", seoHead) .Replace("{{ json_ld }}", "").Replace("{{json_ld}}", "") .Replace("{{sections}}", "").Replace("{{ sections }}", "") .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}}", "").Replace("{{ page_nav }}", "") .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}}", "").Replace("{{ intro }}", "") .Replace("{{positioning}}", "").Replace("{{ positioning }}", "") .Replace("{{publisher}}", "").Replace("{{ publisher }}", "") .Replace("{{content}}", htmlContent).Replace("{{ content }}", htmlContent) .Replace("{{body}}", htmlContent).Replace("{{ body }}", htmlContent); pageHtml = I18n.Substitute(pageHtml, lang); var outputPath = Path.Combine(lld, baseName); File.WriteAllText(outputPath, pageHtml); if (verbose) Console.WriteLine($" Written {outputPath}"); File.AppendAllText(Directories.BuildLogPath, $" Legal: {outputPath}\n"); } } catch (Exception ex) { Console.WriteLine($"ERROR rendering legal {lang}: {ex.Message}"); File.AppendAllText(Directories.BuildLogPath, $"ERROR rendering legal {lang}: {ex.Message}\n{ex.StackTrace}\n\n"); File.AppendAllText(Directories.RunLogPath, $"ERROR rendering legal {lang}: {ex.Message}\n{ex.StackTrace}\n\n"); } } } private static void BuildSitemap(BuildConfig cfg, string baseUrl, bool verbose) { var urls = new List(); foreach (var lang in cfg.LanguageCodes()) { urls.Add($"{baseUrl}/{lang}/"); var contents = ContentLoader.LoadContent(Directories.ContentDir, lang); foreach (var page in contents) { if (!page.Meta.Render) continue; var slug = page.Href.Trim('/'); if (!string.IsNullOrEmpty(slug)) urls.Add($"{baseUrl}/{lang}/{slug}.html"); } } var sitemap = @" {0} "; var urlEntries = string.Join("\n", urls.Select(u => $" {u}")); File.WriteAllText(Path.Combine(Directories.DistDir, "sitemap.xml"), string.Format(sitemap, urlEntries)); if (verbose) Console.WriteLine(" Written sitemap.xml"); } private static string GlossaryHref(string lang) { if (Directories.Config.Glossary.Href.TryGetValue(lang, out var h)) return h.EndsWith(".html") ? h : h + ".html"; return lang == "de" ? "glossar.html" : $"{Directories.Config.GlossarySection}.html"; } private static string GlossaryLabel(string lang) { if (Directories.Config.Glossary.Label.TryGetValue(lang, out var l)) return l; return lang == "de" ? "Glossar" : lang == "en" ? "Glossary" : "Bảng thuật ngữ"; } private static void BuildSearchIndex(bool verbose) { try { var psi = new ProcessStartInfo { FileName = "npx", Arguments = $"pagefind --site \"{Directories.DistDir}\"", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Could not start npx."); var sbOut = new StringBuilder(); var sbErr = new StringBuilder(); proc.OutputDataReceived += (_, e) => { if (e.Data != null) sbOut.AppendLine(e.Data); }; proc.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); }; proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); proc.WaitForExit(); File.AppendAllText(Directories.BuildLogPath, $" Pagefind (exit {proc.ExitCode}): {sbOut.ToString().Trim()}\n"); if (proc.ExitCode != 0) File.AppendAllText(Directories.BuildLogPath, $" Pagefind stderr: {sbErr.ToString().Trim()}\n"); } catch (Exception ex) { File.AppendAllText(Directories.BuildLogPath, $" Pagefind skipped (search index not built): {ex.Message}\n"); } } public static string HtmlEncode(string text) { if (string.IsNullOrEmpty(text)) return ""; return text.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); } private static string BuildRootIndex(BuildConfig cfg, string baseUrl) { var supportedArray = string.Join(",", cfg.LanguageCodes().Select(l => $"\"{l}\"")); var defaultLang = cfg.DefaultLanguage; var links = string.Join(" | ", cfg.LanguageCodes().Select(l => { var label = cfg.Languages.TryGetValue(l, out var li) ? (li.Label ?? li.Name) : l; return $"{label}"; })); return $@" {HtmlEncode(cfg.Site.Name)}

    Redirecting to {links}

    "; } }