SHA256
Separation as Tool
This commit is contained in:
@@ -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 > file > 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
@@ -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\"> </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\"> </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("&", "&").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 $"<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>";
|
||||
}
|
||||
}
|
||||
@@ -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}/";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.<namespace>.<key> }}</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));
|
||||
}
|
||||
}
|
||||
@@ -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("<", "<").Replace(">", ">").Replace("&", "&");
|
||||
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("<", "<").Replace(">", ">").Replace("&", "&");
|
||||
return $"<div class=\"mermaid\">{code}</div>";
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
public static string TablePostprocess(string html)
|
||||
{
|
||||
return html.Replace("<table>", "<table class=\"gc-table\">");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user