Separation as Tool

This commit is contained in:
2026-07-11 13:43:52 +02:00
commit f542382e58
80 changed files with 5863 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
using System.Text.Json;
namespace GCBoK.WebStaticBuilder;
/// <summary>Per-language configuration.</summary>
public class LanguageConfig
{
public string Name { get; set; } = "";
public string OgLocale { get; set; } = "";
public string? HrefLang { get; set; }
public string? Label { get; set; }
}
/// <summary>Filesystem paths, relative to the site root unless absolute.</summary>
public class PathsConfig
{
public string Content { get; set; } = "content";
public string Templates { get; set; } = "templates";
public string Assets { get; set; } = "assets";
public string Output { get; set; } = "dist";
public string Legal { get; set; } = "legal";
public string I18n { get; set; } = "i18n.json";
}
/// <summary>Build environment (work/review/staging/deploy) with base URL and CDN.</summary>
public class EnvironmentConfig
{
public string BaseUrl { get; set; } = "http://localhost:8000";
public string Cdn { get; set; } = "/assets";
}
/// <summary>Machine-translation helper configuration (optional, behind an API key).</summary>
public class TranslatorConfig
{
public string Provider { get; set; } = "azure"; // "azure" | "deepl"
public string? KeyEnv { get; set; } = "WEBSTATIC_TRANSLATOR_KEY";
public string? Endpoint { get; set; }
public string? Region { get; set; }
}
/// <summary>Per-language glossary link (slug without extension).</summary>
public class GlossaryConfig
{
public Dictionary<string, string> Href { get; set; } = new();
public Dictionary<string, string> Label { get; set; } = new();
}
/// <summary>Site-level metadata.</summary>
public class SiteConfig
{
public string Name { get; set; } = "Site";
public Dictionary<string, string> Title { get; set; } = new();
public Dictionary<string, string> Description { get; set; } = new();
public string? Publisher { get; set; }
}
/// <summary>
/// Full build configuration. Loaded from <c>webstatic.json</c> (CLI &gt; file &gt; defaults).
/// </summary>
public class BuildConfig
{
public SiteConfig Site { get; set; } = new();
public string DefaultLanguage { get; set; } = "de";
public Dictionary<string, LanguageConfig> Languages { get; set; } = new();
public PathsConfig Paths { get; set; } = new();
public GlossaryConfig Glossary { get; set; } = new();
public string GlossarySection { get; set; } = "glossar";
public bool Pagefind { get; set; } = true;
public TranslatorConfig Translator { get; set; } = new();
public Dictionary<string, EnvironmentConfig> Environments { get; set; } = new();
public static BuildConfig Defaults()
{
var cfg = new BuildConfig
{
Languages = new()
{
["de"] = new LanguageConfig { Name = "Deutsch", OgLocale = "de_DE", HrefLang = "de", Label = "Deutsch" },
["en"] = new LanguageConfig { Name = "English", OgLocale = "en_US", HrefLang = "en", Label = "English" },
["vi"] = new LanguageConfig { Name = "Tiếng Việt", OgLocale = "vi_VN", HrefLang = "vi", Label = "Tiếng Việt" },
},
Environments = new()
{
["work"] = new EnvironmentConfig { BaseUrl = "http://localhost:8000", Cdn = "/assets" },
["review"] = new EnvironmentConfig { BaseUrl = "https://review.gitcover.org", Cdn = "https://gitcover.org/assets" },
["staging"] = new EnvironmentConfig { BaseUrl = "https://gcbok.gitcover.org", Cdn = "https://gitcover.org/assets" },
["deploy"] = new EnvironmentConfig { BaseUrl = "https://gcbok.org", Cdn = "https://gitcover.org/assets" },
},
};
return cfg;
}
/// <summary>Load config from a JSON file if present, merged over the built-in defaults.</summary>
public static BuildConfig Load(string? configPath)
{
var cfg = Defaults();
if (string.IsNullOrEmpty(configPath)) return cfg;
if (!File.Exists(configPath)) return cfg;
try
{
var json = File.ReadAllText(configPath);
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var fromFile = JsonSerializer.Deserialize<BuildConfig>(json, opts);
if (fromFile != null)
{
cfg = Merge(cfg, fromFile);
}
}
catch (Exception ex)
{
Console.WriteLine($"WARNING: could not read config '{configPath}': {ex.Message}");
}
return cfg;
}
private static BuildConfig Merge(BuildConfig d, BuildConfig f)
{
if (f.Site != null) d.Site = f.Site;
if (!string.IsNullOrEmpty(f.DefaultLanguage)) d.DefaultLanguage = f.DefaultLanguage;
if (f.Languages != null && f.Languages.Count > 0) d.Languages = f.Languages;
if (f.Paths != null) d.Paths = f.Paths;
if (f.Glossary != null) d.Glossary = f.Glossary;
if (!string.IsNullOrEmpty(f.GlossarySection)) d.GlossarySection = f.GlossarySection;
d.Pagefind = f.Pagefind;
if (f.Translator != null) d.Translator = f.Translator;
if (f.Environments != null && f.Environments.Count > 0) d.Environments = f.Environments;
return d;
}
/// <summary>Ordered list of language codes to render.</summary>
public List<string> LanguageCodes()
{
if (Languages.Count == 0) return new() { DefaultLanguage };
return Languages.Keys.ToList();
}
public bool HasLanguage(string lang) => Languages.ContainsKey(lang);
}
+503
View File
@@ -0,0 +1,503 @@
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
namespace GCBoK.WebStaticBuilder;
/// <summary>Main build orchestrator.</summary>
public static class Builder
{
public static void Build(BuildConfig cfg, EnvironmentConfig env, string envName, bool verbose, bool dryRun)
{
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);
var langLinksHtml = BuildLangLinksHtml();
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 canonical = page.Href.StartsWith("/") ? page.Href : $"/{page.Href}";
if (!canonical.EndsWith("/")) canonical += "/";
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 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("{{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 slug = page.Href.Trim('/');
var outputFile = string.IsNullOrEmpty(slug) ? "index.html" : $"{slug}.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).");
}
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 "<html><body>{{body}}</body></html>";
}
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");
}
private static string BuildLangLinksHtml()
{
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();
sb.AppendLine($" <li><a href=\"/{lang}/\" hreflang=\"{li.HrefLang ?? lang}\"><span>{HtmlEncode(label)}</span> <span class=\"lang-dropdown__code\">{code}</span></a></li>");
}
return sb.ToString().Trim();
}
private static string BuildNavLinksHtml(List<PageInfo> 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($" <a class=\"nav-gcbok__link\" href=\"{href}\">{HtmlEncode(navLabel)}</a>");
}
return sb.ToString().Trim();
}
private static string BuildSidebarNavHtml(List<PageInfo> 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(" <li><a href=\"index.html\"><span class=\"nav-idx\">★</span> Startseite</a></li>");
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($" <li><a href=\"{href}\"><span class=\"nav-idx\">{idx}</span> {HtmlEncode(navLabel)}</a></li>");
}
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($" <li><a href=\"#{slug}\">{HtmlEncode(headingText)}</a></li>");
}
}
return sb.ToString().Trim();
}
private static string BuildPageNavHtml(List<PageInfo> 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("<nav class=\"page-nav\" aria-label=\"Seitennavigation\">");
if (currentIndex > 0)
{
var prev = navPages[currentIndex - 1];
var prevLabel = (prev.Meta.Eyebrow ?? prev.Meta.LocalizedTitle(lang, prev.Meta.Title ?? ""));
if (prevLabel.Contains(" — ")) prevLabel = prevLabel.Substring(prevLabel.IndexOf(" — ") + 3);
sb.AppendLine($" <a class=\"page-nav__link\" href=\"{prev.Href.Trim('/')}.html\">");
sb.AppendLine($" <span class=\"page-nav__label\">← Vorherige</span>");
sb.AppendLine($" <span class=\"page-nav__title\">{HtmlEncode(prevLabel)}</span>");
sb.AppendLine(" </a>");
}
else sb.AppendLine(" <span class=\"page-nav__link\">&nbsp;</span>");
if (currentIndex < navPages.Count - 1)
{
var next = navPages[currentIndex + 1];
var nextLabel = (next.Meta.Eyebrow ?? next.Meta.LocalizedTitle(lang, next.Meta.Title ?? ""));
if (nextLabel.Contains(" — ")) nextLabel = nextLabel.Substring(nextLabel.IndexOf(" — ") + 3);
sb.AppendLine($" <a class=\"page-nav__link page-nav__link--next\" href=\"{next.Href.Trim('/')}.html\">");
sb.AppendLine($" <span class=\"page-nav__label\">Nächste →</span>");
sb.AppendLine($" <span class=\"page-nav__title\">{HtmlEncode(nextLabel)}</span>");
sb.AppendLine(" </a>");
}
else sb.AppendLine(" <span class=\"page-nav__link page-nav__link--next\">&nbsp;</span>");
sb.AppendLine("</nav>");
return sb.ToString().Trim();
}
private static string BuildSectionsHtml(List<PageInfo> 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($" <a href=\"{page.Href}\" data-lang=\"{lang}\">{HtmlEncode(title)}</a>");
}
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();
var langLinksHtml = BuildLangLinksHtml();
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 canonical = $"/{Path.GetFileNameWithoutExtension(baseName)}/";
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 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("{{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<string>();
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) urls.Add($"{baseUrl}{page.Href}");
}
var sitemap = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<urlset xmlns=""http://www.sitemaps.org/schemas/sitemap/0.9"">
{0}
</urlset>";
var urlEntries = string.Join("\n", urls.Select(u => $" <url><loc>{u}</loc></url>"));
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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
}
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 $"<a href=\"/{l}/\">{label}</a>";
}));
return $@"<!DOCTYPE html>
<html lang=""{defaultLang}"">
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>{HtmlEncode(cfg.Site.Name)}</title>
<script>
(function() {{
var supported = [{supportedArray}];
var defaultLang = ""{defaultLang}"";
var lang = defaultLang;
if (navigator.languages && navigator.languages.length) {{
for (var i = 0; i < navigator.languages.length; i++) {{
var pref = navigator.languages[i].toLowerCase().split(""-"")[0];
if (supported.indexOf(pref) !== -1) {{ lang = pref; break; }}
}}
}} else if (navigator.language) {{
var pref = navigator.language.toLowerCase().split(""-"")[0];
if (supported.indexOf(pref) !== -1) lang = pref;
}}
window.location.replace(""/"" + lang + ""/"");
}})();
</script>
<meta http-equiv=""refresh"" content=""0; url=/{defaultLang}/"">
<link rel=""canonical"" href=""{baseUrl}/{defaultLang}/"">
</head>
<body>
<p>Redirecting to {links}</p>
</body>
</html>";
}
}
+108
View File
@@ -0,0 +1,108 @@
using System.Text.RegularExpressions;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace GCBoK.WebStaticBuilder;
/// <summary>Content loading and YAML frontmatter parsing.</summary>
public static class ContentLoader
{
public static List<PageInfo> LoadContent(string contentDir, string lang)
{
var results = new List<PageInfo>();
foreach (var filepath in Directory.GetFiles(contentDir, "*.md", SearchOption.TopDirectoryOnly))
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(filepath);
string? fileLang = null;
if (nameWithoutExt.Contains("."))
{
var parts = nameWithoutExt.Split('.');
if (parts.Length >= 2)
{
var lastPart = parts[^1].ToLowerInvariant();
if (lastPart == "de" || lastPart == "en" || lastPart == "vi")
{
fileLang = lastPart;
if (fileLang != lang) continue;
}
else
{
continue;
}
}
}
var text = File.ReadAllText(filepath);
var (meta, content) = ParseFrontmatter(text);
var langs = meta.Langs ?? "";
if (!string.IsNullOrEmpty(langs) && !langs.Split(',').Select(l => l.Trim()).Contains(lang))
continue;
if (fileLang == null && lang != "de")
{
var baseName = nameWithoutExt;
var langSpecificPath = Path.Combine(contentDir, $"{baseName}.{lang}.md");
if (File.Exists(langSpecificPath))
continue;
}
results.Add(new PageInfo(filepath, meta, GetHref(meta), content));
}
return results.OrderBy(x => x.Meta.Order ?? 999).ToList();
}
public static (PageMeta Meta, string Content) ParseFrontmatter(string text)
{
var lines = text.Split('\n').ToList();
if (lines.Count < 2 || !lines[0].Trim().StartsWith("---"))
return (new PageMeta(), text);
var yamlLines = new List<string>();
int i = 1;
while (i < lines.Count && lines[i].Trim() != "---")
{
yamlLines.Add(lines[i]);
i++;
}
if (i < lines.Count) i++;
var yamlText = string.Join("\n", yamlLines);
var content = string.Join("\n", lines.Skip(i)).Trim();
try
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var meta = deserializer.Deserialize<PageMeta>(yamlText) ?? new PageMeta();
return (meta, content);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to parse frontmatter: {ex.Message}");
return (new PageMeta(), text);
}
}
public static string GetHref(PageMeta meta)
{
if (!string.IsNullOrEmpty(meta.Href))
return meta.Href;
var title = meta.Title ?? "";
var slug = Regex.Replace(
title.ToLower(), @"[^a-z0-9äöüß\-]+", "-").Trim('-');
if (string.IsNullOrEmpty(slug))
slug = "page";
return $"/{slug}/";
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace GCBoK.WebStaticBuilder;
/// <summary>
/// Resolved filesystem paths for a build, derived from <see cref="BuildConfig.Paths"/>
/// relative to the site root. Generic — no longer tied to the GCBoK <c>www/</c> layout.
/// </summary>
public static class Directories
{
public static string Root { get; private set; } = AppContext.BaseDirectory;
public static string BuildDir { get; private set; } = AppContext.BaseDirectory;
public static string ContentDir => Resolve(Config.Paths.Content);
public static string TemplateDir => Resolve(Config.Paths.Templates);
public static string AssetsDir => Resolve(Config.Paths.Assets);
public static string DistDir => Resolve(Config.Paths.Output);
public static string LegalDir => Resolve(Path.Combine(Config.Paths.Content, Config.Paths.Legal));
public static string I18nFile => Resolve(Config.Paths.I18n);
public static BuildConfig Config { get; private set; } = BuildConfig.Defaults();
private static string Resolve(string p)
{
if (Path.IsPathRooted(p)) return p;
return Path.GetFullPath(Path.Combine(Root, p));
}
/// <summary>Initialise paths for a build.</summary>
public static void Init(BuildConfig config, string rootDir)
{
Config = config;
Root = Path.GetFullPath(rootDir);
BuildDir = Root;
}
public static string BuildLogPath => Path.Combine(BuildDir, "build_log.txt");
public static string RunLogPath => Path.Combine(BuildDir, "run_log.txt");
}
+67
View File
@@ -0,0 +1,67 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace GCBoK.WebStaticBuilder;
/// <summary>
/// UI localization. Loads an <c>i18n.json</c> dictionary and substitutes
/// <c>{{ i18n.&lt;namespace&gt;.&lt;key&gt; }}</c> placeholders in templates.
/// Fallback language is English. Ported from the legacy <c>i18n.py</c>.
/// </summary>
public static class I18n
{
private static JsonNode? _root;
private static readonly Regex Placeholder =
new(@"\{\{\s*i18n\.([\w.\-]+)\s*\}\}", RegexOptions.Compiled);
public static void Load(string file)
{
_root = null;
if (!File.Exists(file)) return;
try
{
var json = File.ReadAllText(file);
_root = JsonNode.Parse(json);
}
catch (Exception ex)
{
Console.WriteLine($"WARNING: could not load i18n file '{file}': {ex.Message}");
}
}
public static bool Loaded => _root != null;
/// <summary>Resolve a dotted key path for a language (fallback: English, then the key itself).</summary>
public static string Get(string lang, string keyPath)
{
var value = Resolve(lang, keyPath);
if (value != null) return value;
if (lang != "en")
{
value = Resolve("en", keyPath);
if (value != null) return value;
}
return keyPath;
}
private static string? Resolve(string lang, string keyPath)
{
if (_root == null) return null;
JsonNode? node = _root[lang];
if (node == null) return null;
foreach (var part in keyPath.Split('.'))
{
node = node[part];
if (node == null) return null;
}
return node.GetValueKind() == JsonValueKind.String ? node.GetValue<string>() : null;
}
/// <summary>Replace all <c>{{ i18n.* }}</c> placeholders in a template for the given language.</summary>
public static string Substitute(string template, string lang)
{
if (_root == null) return template;
return Placeholder.Replace(template, m => Get(lang, m.Groups[1].Value));
}
}
+45
View File
@@ -0,0 +1,45 @@
using Markdig;
using System.Text.RegularExpressions;
namespace GCBoK.WebStaticBuilder;
/// <summary>Markdown processing with Markdig and post-processing.</summary>
public static class MarkdownProcessor
{
private static readonly Lazy<MarkdownPipeline> Pipeline = new(() =>
new MarkdownPipelineBuilder().UseAdvancedExtensions().Build());
public static string RenderMarkdown(string content)
{
var pipeline = Pipeline.Value;
return Markdown.ToHtml(content, pipeline);
}
public static string MermaidPostprocess(string html)
{
var pattern1 = @"<pre><code class=""language-mermaid"">(.*?)</code></pre>";
var regex1 = new Regex(pattern1, RegexOptions.Singleline);
html = regex1.Replace(html, m =>
{
var code = m.Groups[1].Value
.Replace("&lt;", "<").Replace("&gt;", ">").Replace("&amp;", "&");
return $"<div class=\"mermaid\">{code}</div>";
});
var pattern2 = @"<pre class=""mermaid"">(.*?)</pre>";
var regex2 = new Regex(pattern2, RegexOptions.Singleline);
html = regex2.Replace(html, m =>
{
var code = m.Groups[1].Value
.Replace("&lt;", "<").Replace("&gt;", ">").Replace("&amp;", "&");
return $"<div class=\"mermaid\">{code}</div>";
});
return html;
}
public static string TablePostprocess(string html)
{
return html.Replace("<table>", "<table class=\"gc-table\">");
}
}
+61
View File
@@ -0,0 +1,61 @@
using YamlDotNet.Serialization;
namespace GCBoK.WebStaticBuilder;
/// <summary>Metadata parsed from a Markdown file's YAML frontmatter.</summary>
public class PageMeta
{
public string? Section { get; set; }
public int? Order { get; set; }
public string? Eyebrow { get; set; }
public string? Title { get; set; }
public string? TitleDe { get; set; }
public string? TitleEn { get; set; }
public string? TitleVi { get; set; }
public string? Description { get; set; }
public string? DescriptionDe { get; set; }
public string? DescriptionEn { get; set; }
public string? DescriptionVi { get; set; }
public string? Href { get; set; }
public string? Langs { get; set; }
public bool Render { get; set; } = true;
public bool Navbar { get; set; } = true;
public string? Intro { get; set; }
public string? Positioning { get; set; }
public string? Publisher { get; set; }
public string? Anchor { get; set; }
}
/// <summary>A loaded content page.</summary>
public record PageInfo(string Path, PageMeta Meta, string Href, string Content);
/// <summary>Helpers to resolve language-specific metadata values.</summary>
public static class PageMetaExtensions
{
public static string LocalizedTitle(this PageMeta meta, string lang, string fallback)
{
var v = lang.ToLowerInvariant() switch
{
"de" => meta.TitleDe ?? meta.Title,
"en" => meta.TitleEn ?? meta.Title,
"vi" => meta.TitleVi ?? meta.Title,
_ => meta.Title
};
return string.IsNullOrEmpty(v) ? fallback : v;
}
public static string LocalizedDescription(this PageMeta meta, string lang, string fallback)
{
var v = lang.ToLowerInvariant() switch
{
"de" => meta.DescriptionDe ?? meta.Description,
"en" => meta.DescriptionEn ?? meta.Description,
"vi" => meta.DescriptionVi ?? meta.Description,
_ => meta.Description
};
return string.IsNullOrEmpty(v) ? fallback : v;
}
}
+210
View File
@@ -0,0 +1,210 @@
using System.Net;
using System.Text;
using GCBoK.WebStaticBuilder;
var argsList = args.ToList();
bool Has(string f) => argsList.Contains(f) || argsList.Contains(f switch { "--verbose" => "-v", "--dry-run" => "-n", _ => f });
string? Val(string f)
{
var i = argsList.FindIndex(a => a == f);
return i >= 0 && i + 1 < argsList.Count ? argsList[i + 1] : null;
}
var verbose = Has("--verbose") || Has("-v");
var dryRun = Has("--dry-run") || Has("-n");
// Resolve config file + site root
var configPath = Val("--config");
var root = Val("--root") ?? Directory.GetCurrentDirectory();
if (!string.IsNullOrEmpty(configPath))
root = Path.GetFullPath(Path.GetDirectoryName(Path.GetFullPath(configPath))!);
var cfg = BuildConfig.Load(string.IsNullOrEmpty(configPath) ? Path.Combine(root, "webstatic.json") : configPath);
// CLI overrides
if (Val("--content") is { } c) cfg.Paths.Content = c;
if (Val("--templates") is { } t) cfg.Paths.Templates = t;
if (Val("--assets") is { } a) cfg.Paths.Assets = a;
if (Val("--output") is { } o) cfg.Paths.Output = o;
if (Val("--default-lang") is { } dl) cfg.DefaultLanguage = dl;
if (Val("--langs") is { } langs)
{
var wanted = langs.Split(',').Select(x => x.Trim()).Where(x => x.Length > 0).ToList();
if (wanted.Count > 0)
{
var filtered = new Dictionary<string, LanguageConfig>();
foreach (var l in wanted)
if (cfg.Languages.TryGetValue(l, out var lc)) filtered[l] = lc;
else filtered[l] = new LanguageConfig { Name = l, OgLocale = $"{l}_{l.ToUpperInvariant()}" };
cfg.Languages = filtered;
}
}
if (Val("--base-url") is { } bu) { /* applied to chosen env below */ }
if (Val("--cdn") is { } cdn) { /* applied to chosen env below */ }
Directories.Init(cfg, root);
// Subcommand: translate
if (argsList.Contains("translate"))
{
if (Has("--draft"))
await TranslationService.GenerateDrafts(cfg);
else
await TranslationService.CheckMissing(cfg);
return;
}
// Subcommand: serve
if (argsList.Contains("serve"))
{
var port = int.TryParse(Val("--port"), out var p) ? p : 8000;
await ServeAsync(Directories.DistDir, port);
return;
}
// Subcommand: init <name>
if (argsList.Contains("init"))
{
var nameIdx = argsList.IndexOf("init") + 1;
var siteName = nameIdx < argsList.Count ? argsList[nameIdx] : "mysite";
InitSite(siteName, root);
return;
}
// Build
var envName = "work";
if (Has("--preview")) envName = "work";
if (Has("--work")) envName = "work";
if (Has("--review")) envName = "review";
if (Has("--staging")) envName = "staging";
if (Has("--deploy")) envName = "deploy";
if (Val("--env") is { } ev) envName = ev;
var env = cfg.Environments.TryGetValue(envName, out var e)
? e
: new EnvironmentConfig { BaseUrl = "http://localhost:8000", Cdn = "/assets" };
if (Val("--base-url") is { } bu2) env.BaseUrl = bu2;
if (Val("--cdn") is { } cdn2) env.Cdn = cdn2;
if (verbose) Console.WriteLine($"GCBoK.WebStaticBuilder — building (env={envName})");
Builder.Build(cfg, env, envName, verbose, dryRun);
Console.WriteLine("Build completed successfully.");
static string FindExampleDir()
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "webstatic.example"),
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "webstatic.example"),
"/opt/GitCover/webstatic/webstatic.example",
};
foreach (var c in candidates)
if (Directory.Exists(Path.GetFullPath(c))) return Path.GetFullPath(c);
return "";
}
static void InitSite(string name, string rootDir)
{
var example = FindExampleDir();
if (string.IsNullOrEmpty(example))
{
Console.WriteLine("ERROR: webstatic.example template not found (expected next to the tool).");
return;
}
var target = Path.Combine(rootDir, name);
if (Directory.Exists(target))
{
Console.WriteLine($"ERROR: target '{target}' already exists.");
return;
}
CopyDir(example, target);
Console.WriteLine($"Initialized site scaffold at {target}");
Console.WriteLine($"Next: cd {name} && webstatic build --env work");
}
static void CopyDir(string src, string dst)
{
Directory.CreateDirectory(dst);
foreach (var dir in Directory.GetDirectories(src, "*", SearchOption.AllDirectories))
Directory.CreateDirectory(dir.Replace(src, dst));
foreach (var file in Directory.GetFiles(src, "*", SearchOption.AllDirectories))
File.Copy(file, file.Replace(src, dst), true);
}
static async Task ServeAsync(string distDir, int port)
{
if (!Directory.Exists(distDir))
{
Console.WriteLine($"ERROR: output directory '{distDir}' not found. Run a build first.");
return;
}
var prefix = $"http://localhost:{port}/";
using var listener = new HttpListener();
listener.Prefixes.Add(prefix);
listener.Start();
Console.WriteLine($"Serving {distDir} at {prefix} (Ctrl+C to stop)");
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
while (!cts.IsCancellationRequested)
{
var ctx = await listener.GetContextAsync();
_ = Task.Run(async () =>
{
try
{
var reqPath = Uri.UnescapeDataString(ctx.Request.Url!.LocalPath);
if (reqPath.EndsWith("/")) reqPath += "index.html";
var file = Path.Combine(distDir, reqPath.TrimStart('/'));
if (!File.Exists(file))
{
file = Path.Combine(distDir, reqPath.TrimStart('/') + "/index.html");
if (!File.Exists(file)) file = Path.Combine(distDir, "404.html");
}
if (File.Exists(file))
{
var bytes = await File.ReadAllBytesAsync(file);
ctx.Response.ContentType = MimeType(file);
await ctx.Response.OutputStream.WriteAsync(bytes);
}
else
{
ctx.Response.StatusCode = 404;
}
}
catch (Exception ex)
{
ctx.Response.StatusCode = 500;
var msg = Encoding.UTF8.GetBytes(ex.Message);
await ctx.Response.OutputStream.WriteAsync(msg);
}
finally
{
ctx.Response.Close();
}
});
}
listener.Stop();
}
static string MimeType(string file)
{
var ext = Path.GetExtension(file).ToLowerInvariant();
return ext switch
{
".html" => "text/html; charset=utf-8",
".css" => "text/css; charset=utf-8",
".js" => "text/javascript",
".json" => "application/json",
".svg" => "image/svg+xml",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".ico" => "image/x-icon",
".webmanifest" => "application/manifest+json",
".xml" => "application/xml",
".txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
};
}
+54
View File
@@ -0,0 +1,54 @@
using System.Text;
namespace GCBoK.WebStaticBuilder;
/// <summary>SEO header generation (title, meta, OpenGraph, Twitter, canonical, favicons).</summary>
public static class SeoHelper
{
public static string RenderSeoHead(string lang, string title, string description,
string canonicalUrl, string cdnAssets, string defaultTitle, string defaultDescription)
{
var ogTitle = Localized(title, defaultTitle);
var ogDescription = Localized(description, defaultDescription);
var ogLocale = Directories.Config.Languages.TryGetValue(lang, out var li)
? li.OgLocale : "de_DE";
var cdn = cdnAssets.TrimEnd('/');
return $@"<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>{HtmlEncode(ogTitle)}</title>
<meta name=""description"" content=""{HtmlEncode(ogDescription)}"">
<link rel=""icon"" type=""image/svg+xml"" href=""{cdn}/favicon/favicon.svg"">
<link rel=""icon"" type=""image/png"" sizes=""96x96"" href=""{cdn}/favicon/favicon-96x96.png"">
<link rel=""icon"" href=""{cdn}/favicon/favicon.ico"">
<link rel=""apple-touch-icon"" sizes=""180x180"" href=""{cdn}/favicon/apple-touch-icon.png"">
<link rel=""manifest"" href=""{cdn}/favicon/site.webmanifest"">
<meta name=""msapplication-TileColor"" content=""#da532c"">
<meta name=""theme-color"" content=""#ffffff"">
<meta property=""og:title"" content=""{HtmlEncode(ogTitle)}"">
<meta property=""og:description"" content=""{HtmlEncode(ogDescription)}"">
<meta property=""og:type"" content=""website"">
<meta property=""og:url"" content=""{canonicalUrl}"">
<meta property=""og:locale"" content=""{ogLocale}"">
<meta property=""og:image"" content=""{cdn}/favicon/web-app-manifest-512x512.png"">
<meta name=""twitter:card"" content=""summary_large_image"">
<meta name=""twitter:title"" content=""{HtmlEncode(ogTitle)}"">
<meta name=""twitter:description"" content=""{HtmlEncode(ogDescription)}"">
<link rel=""canonical"" href=""{canonicalUrl}"">";
}
private static string Localized(string value, string fallback)
=> string.IsNullOrEmpty(value) ? fallback : value;
public static string HtmlEncode(string text)
{
if (string.IsNullOrEmpty(text)) return "";
return text.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#x27;");
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace GCBoK.WebStaticBuilder;
/// <summary>Machine-translation provider (optional, requires an API key via environment).</summary>
public interface ITranslator
{
/// <summary>Translate text from the site default language into <paramref name="targetLang"/>.</summary>
Task<string> TranslateAsync(string text, string targetLang);
}
+174
View File
@@ -0,0 +1,174 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
namespace GCBoK.WebStaticBuilder;
/// <summary>
/// Machine-translation helper. Talks to Azure Translator or DeepL over REST
/// (no extra NuGet packages). Keys are read from an environment variable.
/// Drafts are written to <c>.translation-drafts/</c> and never overwrite source.
/// </summary>
public static class TranslationService
{
public static ITranslator? Create(BuildConfig cfg)
{
var t = cfg.Translator;
var key = t.KeyEnv != null ? Environment.GetEnvironmentVariable(t.KeyEnv) : null;
if (string.IsNullOrEmpty(key))
{
Console.WriteLine($"No translator key found in environment '{t.KeyEnv}' — translation skipped.");
return null;
}
return t.Provider.ToLowerInvariant() switch
{
"deepl" => new DeeplTranslator(key, t.Endpoint),
_ => new AzureTranslator(key, t.Endpoint, t.Region),
};
}
public static async Task CheckMissing(BuildConfig cfg)
{
var root = Directories.ContentDir;
var langs = cfg.LanguageCodes().Where(l => l != cfg.DefaultLanguage).ToList();
var files = Directory.GetFiles(root, "*.md", SearchOption.TopDirectoryOnly);
var missing = new List<(string File, string Lang)>();
foreach (var f in files)
{
var name = Path.GetFileNameWithoutExtension(f);
if (name.Contains('.')) continue; // skip already language-specific
foreach (var lang in langs)
{
if (!File.Exists(Path.Combine(root, $"{name}.{lang}.md")))
missing.Add((Path.GetFileName(f), lang));
}
}
if (missing.Count == 0)
{
Console.WriteLine("All languages have content files. Nothing missing.");
return;
}
Console.WriteLine($"Missing translations ({missing.Count}):");
foreach (var (file, lang) in missing)
Console.WriteLine($" [{lang}] {file}");
}
public static async Task GenerateDrafts(BuildConfig cfg)
{
var translator = Create(cfg);
if (translator == null) return;
var root = Directories.ContentDir;
var draftRoot = Path.Combine(Directories.Root, ".translation-drafts");
var langs = cfg.LanguageCodes().Where(l => l != cfg.DefaultLanguage).ToList();
var files = Directory.GetFiles(root, "*.md", SearchOption.TopDirectoryOnly);
var count = 0;
foreach (var f in files)
{
var name = Path.GetFileNameWithoutExtension(f);
if (name.Contains('.')) continue;
var (meta, body) = ContentLoader.ParseFrontmatter(File.ReadAllText(f));
foreach (var lang in langs)
{
var targetFile = Path.Combine(draftRoot, lang, $"{name}.{lang}.md");
if (File.Exists(Path.Combine(root, $"{name}.{lang}.md"))) continue; // already exists
if (File.Exists(targetFile)) continue; // draft already there
var tTitle = await translator.TranslateAsync(meta.Title ?? "", lang);
var tDesc = await translator.TranslateAsync(meta.Description ?? "", lang);
var tEyebrow = await translator.TranslateAsync(meta.Eyebrow ?? "", lang);
var tBody = await translator.TranslateAsync(body, lang);
var sb = new StringBuilder();
sb.AppendLine("---");
sb.AppendLine($"section: {meta.Section}");
sb.AppendLine($"order: {meta.Order}");
sb.AppendLine($"eyebrow: \"{tEyebrow}\"");
sb.AppendLine($"title: \"{tTitle}\"");
sb.AppendLine($"description: \"{tDesc}\"");
sb.AppendLine($"href: {meta.Href}");
sb.AppendLine($"render: {meta.Render.ToString().ToLowerInvariant()}");
sb.AppendLine($"navbar: {meta.Navbar.ToString().ToLowerInvariant()}");
sb.AppendLine("---");
sb.AppendLine();
sb.AppendLine($"<!-- DRAFT (machine-translated via {cfg.Translator.Provider}). Review before publishing. -->");
sb.AppendLine(tBody);
Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!);
File.WriteAllText(targetFile, sb.ToString());
count++;
Console.WriteLine($" Draft -> {Path.GetRelativePath(Directories.Root, targetFile)}");
}
}
Console.WriteLine($"Generated {count} translation draft(s) in {draftRoot}");
}
}
/// <summary>Azure Translator (Text Translation API) REST client.</summary>
public class AzureTranslator : ITranslator
{
private readonly HttpClient _http = new();
private readonly string _key;
private readonly string? _endpoint;
private readonly string? _region;
public AzureTranslator(string key, string? endpoint, string? region)
{
_key = key;
_endpoint = string.IsNullOrEmpty(endpoint) ? "https://api.cognitive.microsofttranslator.com" : endpoint;
_region = region;
_http.Timeout = TimeSpan.FromSeconds(30);
}
public async Task<string> TranslateAsync(string text, string targetLang)
{
if (string.IsNullOrWhiteSpace(text)) return text;
var url = $"{_endpoint!.TrimEnd('/')}/translate?api-version=3.0&to={targetLang}";
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Add("Ocp-Apim-Subscription-Key", _key);
if (!string.IsNullOrEmpty(_region)) req.Headers.Add("Ocp-Apim-Subscription-Region", _region);
req.Content = new StringContent(JsonSerializer.Serialize(new[] { new { Text = text } }),
Encoding.UTF8, "application/json");
using var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
var translated = doc[0].GetProperty("translations")[0].GetProperty("text").GetString();
return translated ?? text;
}
}
/// <summary>DeepL REST client.</summary>
public class DeeplTranslator : ITranslator
{
private readonly HttpClient _http = new();
private readonly string _key;
private readonly string _endpoint;
public DeeplTranslator(string key, string? endpoint)
{
_key = key;
_endpoint = string.IsNullOrEmpty(endpoint) ? "https://api-free.deepl.com/v2" : endpoint.TrimEnd('/');
_http.Timeout = TimeSpan.FromSeconds(30);
}
public async Task<string> TranslateAsync(string text, string targetLang)
{
if (string.IsNullOrWhiteSpace(text)) return text;
var target = targetLang.ToUpperInvariant();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["text"] = text,
["target_lang"] = target,
});
using var req = new HttpRequestMessage(HttpMethod.Post, $"{_endpoint}/translate") { Content = content };
req.Headers.Authorization = new AuthenticationHeaderValue("DeepL-Auth-Key", _key);
using var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
var translated = doc.GetProperty("translations")[0].GetProperty("text").GetString();
return translated ?? text;
}
}