Files
build_webstatic/src/Builder.cs
T
gc-dev-afjd 7065c0773c Link-Fixes: Sprachumschalter, Canonical, og:locale, Legal-Platzhalter, serve-Fallback
- Builder.cs: Sprachumschalter-Links verwenden /{lang}/{slug}.html statt
  /{lang}/{slug}/ (Dateien liegen flach als lang/slug.html)
- Builder.cs: Canonical/OG-URL enthält jetzt /{lang}/-Präfix
- Builder.cs: Legal-Seiten ersetzen {{intro}}, {{positioning}}, {{publisher}}
  mit leerem String (waren zuvor als Artefakte im HTML sichtbar)
- Builder.cs: Sitemap-URLs enthalten Sprach-Präfix + .html
- BuildConfig.cs: JsonNamingPolicy.SnakeCaseLower für korrekte Deserialisierung
  von snake_case JSON-Properties (og_locale → OgLocale, base_url → BaseUrl)
- Program.cs: webstatic serve hat .html-Fallback ( /de/slug → de/slug.html)
2026-07-11 18:41:49 +02:00

569 lines
28 KiB
C#

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