| 1 | #!/usr/bin/env dotnet-script |
| 2 | #r "nuget: Markdig, 0.37.0" |
| 3 | #r "nuget: YamlDotNet, 16.3.0" |
| 4 | |
| 5 | #nullable enable |
| 6 | |
| 7 | using System.Linq; |
| 8 | using System.Net; |
| 9 | using System.Text; |
| 10 | using System.Text.RegularExpressions; |
| 11 | using Markdig; |
| 12 | using YamlDotNet.Serialization; |
| 13 | using YamlDotNet.Serialization.NamingConventions; |
| 14 | |
| 15 | var repoRoot = Args.Count > 0 && !string.IsNullOrWhiteSpace(Args[0]) |
| 16 | ? Path.GetFullPath(Args[0]) |
| 17 | : Directory.GetCurrentDirectory(); |
| 18 | |
| 19 | var srcEn = Path.Combine(repoRoot, "src", "writing", "en"); |
| 20 | var srcRu = Path.Combine(repoRoot, "src", "writing", "ru"); |
| 21 | var docsOut = Path.Combine(repoRoot, "docs"); |
| 22 | var writingEnOut = Path.Combine(docsOut, "writing"); |
| 23 | var writingRuOut = Path.Combine(docsOut, "ru", "writing"); |
| 24 | |
| 25 | if (!Directory.Exists(srcEn) || !Directory.Exists(srcRu)) |
| 26 | { |
| 27 | Console.Error.WriteLine("Expected src/writing/en and src/writing/ru under: " + repoRoot); |
| 28 | Environment.Exit(1); |
| 29 | } |
| 30 | |
| 31 | Directory.CreateDirectory(writingEnOut); |
| 32 | Directory.CreateDirectory(writingRuOut); |
| 33 | |
| 34 | var mdPipeline = new MarkdownPipelineBuilder() |
| 35 | .UseAdvancedExtensions() |
| 36 | .Build(); |
| 37 | |
| 38 | var yaml = new DeserializerBuilder() |
| 39 | .WithNamingConvention(UnderscoredNamingConvention.Instance) |
| 40 | .IgnoreUnmatchedProperties() |
| 41 | .Build(); |
| 42 | |
| 43 | var enArticles = LoadArticles(srcEn, yaml, mdPipeline); |
| 44 | var ruArticles = LoadArticles(srcRu, yaml, mdPipeline); |
| 45 | |
| 46 | foreach (var a in enArticles) |
| 47 | WriteArticlePage(a, "en", writingEnOut, "/writing/"); |
| 48 | foreach (var a in ruArticles) |
| 49 | WriteArticlePage(a, "ru", writingRuOut, "/ru/writing/"); |
| 50 | |
| 51 | WriteIndex(enArticles.OrderBy(x => x.Order).ThenBy(x => x.Slug).ToList(), "en", writingEnOut); |
| 52 | WriteIndex(ruArticles.OrderBy(x => x.Order).ThenBy(x => x.Slug).ToList(), "ru", writingRuOut); |
| 53 | |
| 54 | WriteTagDirectory(enArticles, "en", writingEnOut, "/writing/"); |
| 55 | WriteTagDirectory(ruArticles, "ru", writingRuOut, "/ru/writing/"); |
| 56 | |
| 57 | WriteAtomFeed(enArticles, "en", writingEnOut, "/writing/"); |
| 58 | WriteAtomFeed(ruArticles, "ru", writingRuOut, "/ru/writing/"); |
| 59 | |
| 60 | Console.WriteLine($"Writing: {enArticles.Count} EN + {ruArticles.Count} RU articles → docs/writing/ (+ tags, atom)"); |
| 61 | |
| 62 | static List<Article> LoadArticles(string dir, IDeserializer yaml, MarkdownPipeline mdPipeline) |
| 63 | { |
| 64 | var list = new List<Article>(); |
| 65 | foreach (var path in Directory.EnumerateFiles(dir, "*.md")) |
| 66 | { |
| 67 | var text = File.ReadAllText(path, Encoding.UTF8); |
| 68 | var (front, body) = SplitFrontMatter(text); |
| 69 | var meta = yaml.Deserialize<FrontMatter>(front) ?? throw new InvalidOperationException("YAML: " + path); |
| 70 | if (string.IsNullOrWhiteSpace(meta.Slug) || string.IsNullOrWhiteSpace(meta.Title)) |
| 71 | throw new InvalidOperationException("slug/title required: " + path); |
| 72 | |
| 73 | var html = Markdown.ToHtml(body.Trim(), mdPipeline); |
| 74 | var tags = NormalizeTags(meta.Tags ?? new List<string>()); |
| 75 | var provenance = BuildProvenanceFromFrontMatter(meta); |
| 76 | list.Add(new Article(meta.Slug, meta.Title, meta.Description ?? "", meta.Date_display ?? "", meta.Order, html, tags, provenance)); |
| 77 | } |
| 78 | return list; |
| 79 | } |
| 80 | |
| 81 | static CoauthorshipProvenance? BuildProvenanceFromFrontMatter(FrontMatter meta) |
| 82 | { |
| 83 | if (!meta.Show_provenance) |
| 84 | return null; |
| 85 | |
| 86 | var author = TrimOrNull(meta.Provenance_author); |
| 87 | var coauthors = meta.Provenance_coauthors? |
| 88 | .Where(s => !string.IsNullOrWhiteSpace(s)) |
| 89 | .Select(s => s.Trim()) |
| 90 | .ToList() ?? new List<string>(); |
| 91 | var contribution = TrimOrNull(meta.Provenance_contribution); |
| 92 | var humanFinal = TrimOrNull(meta.Provenance_human_final); |
| 93 | |
| 94 | if (author is null && coauthors.Count == 0 && contribution is null && humanFinal is null) |
| 95 | return null; |
| 96 | |
| 97 | return new CoauthorshipProvenance(author, coauthors, contribution, humanFinal); |
| 98 | } |
| 99 | |
| 100 | static string? TrimOrNull(string? s) |
| 101 | { |
| 102 | if (string.IsNullOrWhiteSpace(s)) |
| 103 | return null; |
| 104 | var t = s.Trim(); |
| 105 | return t.Length == 0 ? null : t; |
| 106 | } |
| 107 | |
| 108 | /// <summary>Уникальные теги: slug для URL, display — первая встреченная строка из YAML.</summary> |
| 109 | static IReadOnlyList<ArticleTag> NormalizeTags(List<string> raw) |
| 110 | { |
| 111 | if (raw.Count == 0) |
| 112 | return Array.Empty<ArticleTag>(); |
| 113 | |
| 114 | var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| 115 | foreach (var t in raw) |
| 116 | { |
| 117 | if (string.IsNullOrWhiteSpace(t)) |
| 118 | continue; |
| 119 | var s = t.Trim(); |
| 120 | string display; |
| 121 | string slug; |
| 122 | var colon = s.IndexOf(':'); |
| 123 | if (colon > 0 && colon < s.Length - 1) |
| 124 | { |
| 125 | display = s[..colon].Trim(); |
| 126 | var slugRaw = s[(colon + 1)..].Trim(); |
| 127 | slug = TagToSlug(slugRaw); |
| 128 | if (string.IsNullOrEmpty(slug)) |
| 129 | slug = TagToSlug(display); |
| 130 | if (string.IsNullOrEmpty(display)) |
| 131 | display = string.IsNullOrEmpty(slugRaw) ? slug : slugRaw; |
| 132 | } |
| 133 | else |
| 134 | { |
| 135 | display = s; |
| 136 | slug = TagToSlug(s); |
| 137 | } |
| 138 | |
| 139 | if (string.IsNullOrEmpty(slug)) |
| 140 | continue; |
| 141 | if (!map.ContainsKey(slug)) |
| 142 | map[slug] = display; |
| 143 | } |
| 144 | |
| 145 | return map.OrderBy(kv => kv.Key, StringComparer.Ordinal) |
| 146 | .Select(kv => new ArticleTag(kv.Value, kv.Key)) |
| 147 | .ToList(); |
| 148 | } |
| 149 | |
| 150 | static string TagToSlug(string s) |
| 151 | { |
| 152 | var lower = s.Trim().ToLowerInvariant(); |
| 153 | var sb = new StringBuilder(); |
| 154 | foreach (var c in lower) |
| 155 | { |
| 156 | if (c is >= 'a' and <= 'z' or >= '0' and <= '9') |
| 157 | sb.Append(c); |
| 158 | else if (c is ' ' or '-' or '_') |
| 159 | { |
| 160 | if (sb.Length > 0 && sb[^1] != '-') |
| 161 | sb.Append('-'); |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | var r = sb.ToString().Trim('-'); |
| 166 | r = Regex.Replace(r, "-{2,}", "-"); |
| 167 | return r; |
| 168 | } |
| 169 | |
| 170 | static (string Yaml, string Body) SplitFrontMatter(string text) |
| 171 | { |
| 172 | if (!text.StartsWith("---", StringComparison.Ordinal)) |
| 173 | return ("", text); |
| 174 | var end = text.IndexOf("\n---", 3, StringComparison.Ordinal); |
| 175 | if (end < 0) |
| 176 | return ("", text); |
| 177 | var yamlBlock = text[3..end].Trim(); |
| 178 | var body = text[(end + "\n---".Length)..]; |
| 179 | return (yamlBlock, body); |
| 180 | } |
| 181 | |
| 182 | static void WriteArticlePage(Article a, string lang, string outDir, string writingBase) |
| 183 | { |
| 184 | var isEn = lang == "en"; |
| 185 | var labels = isEn |
| 186 | ? new NavLabels("Skip to content", "Main navigation", "Language", "About", "Open stack", "Writing", "Handbook", "GitHub", "All writing", "Email", "Telegram", "GitHub", "All tags") |
| 187 | : new NavLabels("К содержанию", "Основная навигация", "Язык", "О нас", "Open stack", "Тексты", "Handbook", "GitHub", "Все тексты", "Email", "Telegram", "GitHub", "Все теги"); |
| 188 | |
| 189 | var home = isEn ? "/" : "/ru/"; |
| 190 | var fileName = a.Slug + ".html"; |
| 191 | var enUrl = "/writing/" + fileName; |
| 192 | var ruUrl = "/ru/writing/" + fileName; |
| 193 | var pageTitle = WebUtility.HtmlEncode(a.Title) + (isEn ? " — AI-Guiders" : " — AI-Guiders"); |
| 194 | var siteName = isEn ? "AI-Guiders" : "AI-Guiders"; |
| 195 | var logo = isEn ? "AI<span>-</span>Guiders" : "AI<span>-</span>Guiders"; |
| 196 | var tagsBase = writingBase + "tag/"; |
| 197 | |
| 198 | var sb = new StringBuilder(); |
| 199 | sb.AppendLine("<!DOCTYPE html>"); |
| 200 | sb.AppendLine($"<html lang=\"{lang}\">"); |
| 201 | sb.AppendLine("<head>"); |
| 202 | sb.AppendLine(" <meta charset=\"utf-8\" />"); |
| 203 | sb.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />"); |
| 204 | sb.AppendLine($" <title>{pageTitle}</title>"); |
| 205 | sb.AppendLine($" <meta name=\"description\" content=\"{WebUtility.HtmlEncode(a.Description)}\" />"); |
| 206 | sb.AppendLine($" <link rel=\"alternate\" hreflang=\"en\" href=\"{enUrl}\" />"); |
| 207 | sb.AppendLine($" <link rel=\"alternate\" hreflang=\"ru\" href=\"{ruUrl}\" />"); |
| 208 | sb.AppendLine($" <link rel=\"alternate\" hreflang=\"x-default\" href=\"{enUrl}\" />"); |
| 209 | sb.AppendLine(" <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>\" />"); |
| 210 | sb.AppendLine(" <link rel=\"stylesheet\" href=\"/assets/css/articles.css\" />"); |
| 211 | sb.AppendLine($" <link rel=\"alternate\" type=\"application/atom+xml\" title=\"{(isEn ? "Writing (Atom)" : "Тексты (Atom)")}\" href=\"{(isEn ? "/writing/atom.xml" : "/ru/writing/atom.xml")}\" />"); |
| 212 | sb.AppendLine("</head>"); |
| 213 | sb.AppendLine("<body>"); |
| 214 | sb.AppendLine($" <a href=\"#main\" class=\"skip-link\">{labels.SkipToContent}</a>"); |
| 215 | sb.AppendLine(); |
| 216 | sb.AppendLine($" <nav aria-label=\"{WebUtility.HtmlEncode(labels.NavMain)}\">"); |
| 217 | sb.AppendLine(" <div class=\"nav-inner\">"); |
| 218 | sb.AppendLine($" <div class=\"logo\"><a href=\"{home}\">{logo}</a></div>"); |
| 219 | sb.AppendLine(" <div class=\"nav-right\">"); |
| 220 | sb.AppendLine(" <ul>"); |
| 221 | sb.AppendLine($" <li><a href=\"{home}#about\">{labels.About}</a></li>"); |
| 222 | sb.AppendLine($" <li><a href=\"{home}#stack\">{labels.Projects}</a></li>"); |
| 223 | sb.AppendLine($" <li><a href=\"{writingBase}\" aria-current=\"page\">{labels.Writing}</a></li>"); |
| 224 | sb.AppendLine($" <li><a href=\"https://github.com/AI-Guiders/handbook/wiki\">{labels.Experience}</a></li>"); |
| 225 | sb.AppendLine($" <li><a href=\"https://github.com/AI-Guiders\">{labels.Docs}</a></li>"); |
| 226 | sb.AppendLine(" </ul>"); |
| 227 | sb.AppendLine($" <ul class=\"nav-lang\" aria-label=\"{WebUtility.HtmlEncode(labels.Language)}\">"); |
| 228 | sb.AppendLine($" <li><a href=\"{enUrl}\" lang=\"en\"{(isEn ? " aria-current=\"page\"" : "")}>EN</a></li>"); |
| 229 | sb.AppendLine(" <li class=\"nav-sep\" aria-hidden=\"true\">|</li>"); |
| 230 | sb.AppendLine($" <li><a href=\"{ruUrl}\" lang=\"ru\"{(!isEn ? " aria-current=\"page\"" : "")}>RU</a></li>"); |
| 231 | sb.AppendLine(" </ul>"); |
| 232 | sb.AppendLine(" </div>"); |
| 233 | sb.AppendLine(" </div>"); |
| 234 | sb.AppendLine(" </nav>"); |
| 235 | sb.AppendLine(); |
| 236 | sb.AppendLine(" <main id=\"main\" class=\"container\">"); |
| 237 | sb.AppendLine(" <article class=\"article-wrap\">"); |
| 238 | sb.AppendLine($" <p class=\"article-back\"><a href=\"{writingBase}\">← {labels.AllWriting}</a> · <a href=\"{writingBase}tags.html\">{labels.AllTags}</a> · <a href=\"{writingBase}atom.xml\">{(isEn ? "Atom feed" : "Лента Atom")}</a></p>"); |
| 239 | sb.AppendLine(); |
| 240 | sb.AppendLine(" <header class=\"article-header\">"); |
| 241 | sb.AppendLine($" <h1>{WebUtility.HtmlEncode(a.Title)}</h1>"); |
| 242 | sb.AppendLine($" <p class=\"article-meta\">{WebUtility.HtmlEncode(a.DateDisplay)}</p>"); |
| 243 | if (a.Tags.Count > 0) |
| 244 | { |
| 245 | sb.AppendLine(" <p class=\"article-tags\" aria-label=\"tags\">"); |
| 246 | foreach (var t in a.Tags) |
| 247 | sb.AppendLine($" <a class=\"tag-pill\" href=\"{tagsBase}{t.Slug}.html\">{WebUtility.HtmlEncode(t.Display)}</a>"); |
| 248 | sb.AppendLine(" </p>"); |
| 249 | } |
| 250 | sb.AppendLine(" </header>"); |
| 251 | if (a.Provenance is { } pv) |
| 252 | { |
| 253 | sb.AppendLine(); |
| 254 | sb.AppendLine(Indent(BuildProvenanceAside(pv, isEn), " ")); |
| 255 | sb.AppendLine(); |
| 256 | } |
| 257 | else |
| 258 | { |
| 259 | sb.AppendLine(); |
| 260 | } |
| 261 | sb.AppendLine(" <div class=\"prose\">"); |
| 262 | sb.AppendLine(Indent(a.BodyHtml, " ")); |
| 263 | sb.AppendLine(" </div>"); |
| 264 | sb.AppendLine(" </article>"); |
| 265 | sb.AppendLine(" </main>"); |
| 266 | sb.AppendLine(); |
| 267 | sb.AppendLine(" <footer id=\"contact\">"); |
| 268 | sb.AppendLine(" <div class=\"container\">"); |
| 269 | sb.AppendLine(" <div class=\"contact-links\">"); |
| 270 | sb.AppendLine($" <a href=\"https://t.me/Krawler\">{labels.Telegram}</a>"); |
| 271 | sb.AppendLine($" <a href=\"https://github.com/AI-Guiders\">{labels.GitHub}</a>"); |
| 272 | sb.AppendLine(" </div>"); |
| 273 | sb.AppendLine($" <p>{siteName} © 2026.</p>"); |
| 274 | sb.AppendLine(" </div>"); |
| 275 | sb.AppendLine(" </footer>"); |
| 276 | sb.AppendLine("</body>"); |
| 277 | sb.AppendLine("</html>"); |
| 278 | |
| 279 | File.WriteAllText(Path.Combine(outDir, fileName), sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); |
| 280 | } |
| 281 | |
| 282 | static void WriteIndex(List<Article> articles, string lang, string outDir) |
| 283 | { |
| 284 | var isEn = lang == "en"; |
| 285 | var labels = isEn |
| 286 | ? new NavLabels("Skip to content", "Main navigation", "Language", "About", "Open stack", "Writing", "Handbook", "GitHub", "All writing", "Email", "Telegram", "GitHub", "All tags") |
| 287 | : new NavLabels("К содержанию", "Основная навигация", "Язык", "О нас", "Open stack", "Тексты", "Handbook", "GitHub", "Все тексты", "Email", "Telegram", "GitHub", "Все теги"); |
| 288 | |
| 289 | var home = isEn ? "/" : "/ru/"; |
| 290 | var writingBase = isEn ? "/writing/" : "/ru/writing/"; |
| 291 | var pageTitle = isEn ? "Writing — AI-Guiders" : "Тексты — AI-Guiders"; |
| 292 | var pageDesc = isEn |
| 293 | ? "Essays on MCP tooling, agent infrastructure, and human–agent parity." |
| 294 | : "Заметки про MCP, инфраструктуру агентов и паритет «люди — агенты»."; |
| 295 | var intro = isEn |
| 296 | ? "Short pieces on why the open-source MCP stack exists and what “parity” between people and agents is meant to achieve." |
| 297 | : "Короткие заметки о том, зачем собран открытый стек MCP и что имеется в виду под паритетом людей и агентов."; |
| 298 | var h1 = isEn ? "Writing" : "Тексты"; |
| 299 | var back = isEn ? "Home" : "На главную"; |
| 300 | var logo = isEn ? "AI<span>-</span>Guiders" : "AI<span>-</span>Guiders"; |
| 301 | var siteName = isEn ? "AI-Guiders" : "AI-Guiders"; |
| 302 | var tagsBase = writingBase + "tag/"; |
| 303 | |
| 304 | var tagCounts = new Dictionary<string, (string Display, int Count)>(StringComparer.OrdinalIgnoreCase); |
| 305 | foreach (var a in articles) |
| 306 | { |
| 307 | foreach (var t in a.Tags) |
| 308 | { |
| 309 | if (!tagCounts.TryGetValue(t.Slug, out var cur)) |
| 310 | tagCounts[t.Slug] = (t.Display, 1); |
| 311 | else |
| 312 | tagCounts[t.Slug] = (cur.Display, cur.Count + 1); |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | var sb = new StringBuilder(); |
| 317 | sb.AppendLine("<!DOCTYPE html>"); |
| 318 | sb.AppendLine($"<html lang=\"{lang}\">"); |
| 319 | sb.AppendLine("<head>"); |
| 320 | sb.AppendLine(" <meta charset=\"utf-8\" />"); |
| 321 | sb.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />"); |
| 322 | sb.AppendLine($" <title>{WebUtility.HtmlEncode(pageTitle)}</title>"); |
| 323 | sb.AppendLine($" <meta name=\"description\" content=\"{WebUtility.HtmlEncode(pageDesc)}\" />"); |
| 324 | sb.AppendLine($" <link rel=\"alternate\" hreflang=\"en\" href=\"/writing/\" />"); |
| 325 | sb.AppendLine($" <link rel=\"alternate\" hreflang=\"ru\" href=\"/ru/writing/\" />"); |
| 326 | sb.AppendLine(" <link rel=\"alternate\" hreflang=\"x-default\" href=\"/writing/\" />"); |
| 327 | sb.AppendLine(" <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>\" />"); |
| 328 | sb.AppendLine(" <link rel=\"stylesheet\" href=\"/assets/css/articles.css\" />"); |
| 329 | sb.AppendLine($" <link rel=\"alternate\" type=\"application/atom+xml\" title=\"{(isEn ? "Writing (Atom)" : "Тексты (Atom)")}\" href=\"{(isEn ? "/writing/atom.xml" : "/ru/writing/atom.xml")}\" />"); |
| 330 | sb.AppendLine("</head>"); |
| 331 | sb.AppendLine("<body>"); |
| 332 | sb.AppendLine($" <a href=\"#main\" class=\"skip-link\">{labels.SkipToContent}</a>"); |
| 333 | sb.AppendLine(); |
| 334 | sb.AppendLine($" <nav aria-label=\"{WebUtility.HtmlEncode(labels.NavMain)}\">"); |
| 335 | sb.AppendLine(" <div class=\"nav-inner\">"); |
| 336 | sb.AppendLine($" <div class=\"logo\"><a href=\"{home}\">{logo}</a></div>"); |
| 337 | sb.AppendLine(" <div class=\"nav-right\">"); |
| 338 | sb.AppendLine(" <ul>"); |
| 339 | sb.AppendLine($" <li><a href=\"{home}#about\">{labels.About}</a></li>"); |
| 340 | sb.AppendLine($" <li><a href=\"{home}#stack\">{labels.Projects}</a></li>"); |
| 341 | sb.AppendLine($" <li><a href=\"{writingBase}\" aria-current=\"page\">{labels.Writing}</a></li>"); |
| 342 | sb.AppendLine($" <li><a href=\"https://github.com/AI-Guiders/handbook/wiki\">{labels.Experience}</a></li>"); |
| 343 | sb.AppendLine($" <li><a href=\"https://github.com/AI-Guiders\">{labels.Docs}</a></li>"); |
| 344 | sb.AppendLine(" </ul>"); |
| 345 | sb.AppendLine($" <ul class=\"nav-lang\" aria-label=\"{WebUtility.HtmlEncode(labels.Language)}\">"); |
| 346 | sb.AppendLine($" <li><a href=\"/writing/\" lang=\"en\"{(isEn ? " aria-current=\"page\"" : "")}>EN</a></li>"); |
| 347 | sb.AppendLine(" <li class=\"nav-sep\" aria-hidden=\"true\">|</li>"); |
| 348 | sb.AppendLine($" <li><a href=\"/ru/writing/\" lang=\"ru\"{(!isEn ? " aria-current=\"page\"" : "")}>RU</a></li>"); |
| 349 | sb.AppendLine(" </ul>"); |
| 350 | sb.AppendLine(" </div>"); |
| 351 | sb.AppendLine(" </div>"); |
| 352 | sb.AppendLine(" </nav>"); |
| 353 | sb.AppendLine(); |
| 354 | sb.AppendLine(" <main id=\"main\" class=\"container\">"); |
| 355 | sb.AppendLine(" <div class=\"article-wrap\">"); |
| 356 | sb.AppendLine($" <p class=\"article-back\"><a href=\"{home}\">← {back}</a></p>"); |
| 357 | sb.AppendLine($" <h1 class=\"page-title\">{WebUtility.HtmlEncode(h1)}</h1>"); |
| 358 | sb.AppendLine($" <p class=\"page-intro\">{intro}</p>"); |
| 359 | sb.AppendLine($" <p class=\"tags-browse\"><a href=\"{writingBase}tags.html\">{labels.AllTags}</a> · <a href=\"{writingBase}atom.xml\">{(isEn ? "Atom feed" : "Лента Atom")}</a></p>"); |
| 360 | if (tagCounts.Count > 0) |
| 361 | { |
| 362 | sb.AppendLine(" <ul class=\"tag-cloud\" aria-label=\"tags\">"); |
| 363 | foreach (var kv in tagCounts.OrderByDescending(x => x.Value.Count).ThenBy(x => x.Key)) |
| 364 | { |
| 365 | sb.AppendLine(" <li>"); |
| 366 | sb.AppendLine($" <a class=\"tag-pill\" href=\"{tagsBase}{kv.Key}.html\">{WebUtility.HtmlEncode(kv.Value.Display)}</a>"); |
| 367 | sb.AppendLine($" <span class=\"tag-count\" aria-hidden=\"true\">{kv.Value.Count}</span>"); |
| 368 | sb.AppendLine(" </li>"); |
| 369 | } |
| 370 | sb.AppendLine(" </ul>"); |
| 371 | } |
| 372 | sb.AppendLine(); |
| 373 | sb.AppendLine(" <ul class=\"writing-list\">"); |
| 374 | foreach (var a in articles) |
| 375 | { |
| 376 | var href = writingBase.TrimEnd('/') + "/" + a.Slug + ".html"; |
| 377 | sb.AppendLine(" <li>"); |
| 378 | sb.AppendLine($" <a class=\"title\" href=\"{href}\">{WebUtility.HtmlEncode(a.Title)}</a>"); |
| 379 | if (a.Tags.Count > 0) |
| 380 | { |
| 381 | sb.AppendLine(" <p class=\"writing-item-tags\">"); |
| 382 | foreach (var t in a.Tags) |
| 383 | sb.AppendLine($" <a class=\"tag-pill tag-pill--sm\" href=\"{tagsBase}{t.Slug}.html\">{WebUtility.HtmlEncode(t.Display)}</a>"); |
| 384 | sb.AppendLine(" </p>"); |
| 385 | } |
| 386 | sb.AppendLine($" <p class=\"blurb\">{WebUtility.HtmlEncode(a.Description)}</p>"); |
| 387 | sb.AppendLine(" </li>"); |
| 388 | } |
| 389 | sb.AppendLine(" </ul>"); |
| 390 | sb.AppendLine(" </div>"); |
| 391 | sb.AppendLine(" </main>"); |
| 392 | sb.AppendLine(); |
| 393 | sb.AppendLine(" <footer id=\"contact\">"); |
| 394 | sb.AppendLine(" <div class=\"container\">"); |
| 395 | sb.AppendLine(" <div class=\"contact-links\">"); |
| 396 | sb.AppendLine($" <a href=\"https://t.me/Krawler\">{labels.Telegram}</a>"); |
| 397 | sb.AppendLine($" <a href=\"https://github.com/AI-Guiders\">{labels.GitHub}</a>"); |
| 398 | sb.AppendLine(" </div>"); |
| 399 | sb.AppendLine($" <p>{siteName} © 2026.</p>"); |
| 400 | sb.AppendLine(" </div>"); |
| 401 | sb.AppendLine(" </footer>"); |
| 402 | sb.AppendLine("</body>"); |
| 403 | sb.AppendLine("</html>"); |
| 404 | |
| 405 | File.WriteAllText(Path.Combine(outDir, "index.html"), sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); |
| 406 | } |
| 407 | |
| 408 | static void WriteTagDirectory(List<Article> articles, string lang, string outDir, string writingBase) |
| 409 | { |
| 410 | var tagOut = Path.Combine(outDir, "tag"); |
| 411 | Directory.CreateDirectory(tagOut); |
| 412 | |
| 413 | var bySlug = new Dictionary<string, (string Display, List<Article> Items)>(StringComparer.OrdinalIgnoreCase); |
| 414 | foreach (var a in articles) |
| 415 | { |
| 416 | foreach (var t in a.Tags) |
| 417 | { |
| 418 | if (!bySlug.TryGetValue(t.Slug, out var entry)) |
| 419 | bySlug[t.Slug] = (t.Display, new List<Article> { a }); |
| 420 | else if (!entry.Items.Any(x => x.Slug == a.Slug)) |
| 421 | entry.Items.Add(a); |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | foreach (var kv in bySlug) |
| 426 | WriteTagPage(kv.Key, kv.Value.Display, kv.Value.Items.OrderBy(x => x.Order).ThenBy(x => x.Slug).ToList(), lang, tagOut, writingBase); |
| 427 | |
| 428 | WriteTagsIndexPage(lang, outDir, writingBase, bySlug); |
| 429 | } |
| 430 | |
| 431 | static void WriteTagsIndexPage( |
| 432 | string lang, |
| 433 | string outDir, |
| 434 | string writingBase, |
| 435 | Dictionary<string, (string Display, List<Article> Items)> bySlug) |
| 436 | { |
| 437 | var isEn = lang == "en"; |
| 438 | var labels = isEn |
| 439 | ? new NavLabels("Skip to content", "Main navigation", "Language", "About", "Open stack", "Writing", "Handbook", "GitHub", "All writing", "Email", "Telegram", "GitHub", "All tags") |
| 440 | : new NavLabels("К содержанию", "Основная навигация", "Язык", "О нас", "Open stack", "Тексты", "Handbook", "GitHub", "Все тексты", "Email", "Telegram", "GitHub", "Все теги"); |
| 441 | |
| 442 | var home = isEn ? "/" : "/ru/"; |
| 443 | var logo = isEn ? "AI<span>-</span>Guiders" : "AI<span>-</span>Guiders"; |
| 444 | var siteName = isEn ? "AI-Guiders" : "AI-Guiders"; |
| 445 | var pageTitle = isEn ? "Tags — AI-Guiders" : "Теги — AI-Guiders"; |
| 446 | var h1 = isEn ? "Tags" : "Теги"; |
| 447 | var back = isEn ? "Writing" : "Тексты"; |
| 448 | var intro = isEn |
| 449 | ? "Browse pieces by topic. Tags use the same labels in EN and RU for each article pair." |
| 450 | : "Тексты по темам. У пары EN/RU у статьи те же теги (латиницей в YAML)."; |
| 451 | |
| 452 | var tagsBase = writingBase + "tag/"; |
| 453 | var sb = new StringBuilder(); |
| 454 | sb.AppendLine("<!DOCTYPE html>"); |
| 455 | sb.AppendLine($"<html lang=\"{lang}\">"); |
| 456 | sb.AppendLine("<head>"); |
| 457 | sb.AppendLine(" <meta charset=\"utf-8\" />"); |
| 458 | sb.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />"); |
| 459 | sb.AppendLine($" <title>{WebUtility.HtmlEncode(pageTitle)}</title>"); |
| 460 | sb.AppendLine($" <link rel=\"alternate\" hreflang=\"en\" href=\"/writing/tags.html\" />"); |
| 461 | sb.AppendLine($" <link rel=\"alternate\" hreflang=\"ru\" href=\"/ru/writing/tags.html\" />"); |
| 462 | sb.AppendLine(" <link rel=\"alternate\" hreflang=\"x-default\" href=\"/writing/tags.html\" />"); |
| 463 | sb.AppendLine(" <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>\" />"); |
| 464 | sb.AppendLine(" <link rel=\"stylesheet\" href=\"/assets/css/articles.css\" />"); |
| 465 | sb.AppendLine($" <link rel=\"alternate\" type=\"application/atom+xml\" title=\"{(isEn ? "Writing (Atom)" : "Тексты (Atom)")}\" href=\"{(isEn ? "/writing/atom.xml" : "/ru/writing/atom.xml")}\" />"); |
| 466 | sb.AppendLine("</head>"); |
| 467 | sb.AppendLine("<body>"); |
| 468 | sb.AppendLine($" <a href=\"#main\" class=\"skip-link\">{labels.SkipToContent}</a>"); |
| 469 | sb.AppendLine(); |
| 470 | sb.AppendLine($" <nav aria-label=\"{WebUtility.HtmlEncode(labels.NavMain)}\">"); |
| 471 | sb.AppendLine(" <div class=\"nav-inner\">"); |
| 472 | sb.AppendLine($" <div class=\"logo\"><a href=\"{home}\">{logo}</a></div>"); |
| 473 | sb.AppendLine(" <div class=\"nav-right\">"); |
| 474 | sb.AppendLine(" <ul>"); |
| 475 | sb.AppendLine($" <li><a href=\"{home}#about\">{labels.About}</a></li>"); |
| 476 | sb.AppendLine($" <li><a href=\"{home}#stack\">{labels.Projects}</a></li>"); |
| 477 | sb.AppendLine($" <li><a href=\"{writingBase}\" aria-current=\"page\">{labels.Writing}</a></li>"); |
| 478 | sb.AppendLine($" <li><a href=\"https://github.com/AI-Guiders/handbook/wiki\">{labels.Experience}</a></li>"); |
| 479 | sb.AppendLine($" <li><a href=\"https://github.com/AI-Guiders\">{labels.Docs}</a></li>"); |
| 480 | sb.AppendLine(" </ul>"); |
| 481 | sb.AppendLine($" <ul class=\"nav-lang\" aria-label=\"{WebUtility.HtmlEncode(labels.Language)}\">"); |
| 482 | sb.AppendLine($" <li><a href=\"/writing/tags.html\" lang=\"en\"{(isEn ? " aria-current=\"page\"" : "")}>EN</a></li>"); |
| 483 | sb.AppendLine(" <li class=\"nav-sep\" aria-hidden=\"true\">|</li>"); |
| 484 | sb.AppendLine($" <li><a href=\"/ru/writing/tags.html\" lang=\"ru\"{(!isEn ? " aria-current=\"page\"" : "")}>RU</a></li>"); |
| 485 | sb.AppendLine(" </ul>"); |
| 486 | sb.AppendLine(" </div>"); |
| 487 | sb.AppendLine(" </div>"); |
| 488 | sb.AppendLine(" </nav>"); |
| 489 | sb.AppendLine(); |
| 490 | sb.AppendLine(" <main id=\"main\" class=\"container\">"); |
| 491 | sb.AppendLine(" <div class=\"article-wrap\">"); |
| 492 | sb.AppendLine($" <p class=\"article-back\"><a href=\"{writingBase}\">← {back}</a> · <a href=\"{writingBase}atom.xml\">{(isEn ? "Atom feed" : "Лента Atom")}</a></p>"); |
| 493 | sb.AppendLine($" <h1 class=\"page-title\">{WebUtility.HtmlEncode(h1)}</h1>"); |
| 494 | sb.AppendLine($" <p class=\"page-intro\">{intro}</p>"); |
| 495 | sb.AppendLine(" <ul class=\"tags-index-list\">"); |
| 496 | foreach (var kv in bySlug.OrderByDescending(x => x.Value.Items.Count).ThenBy(x => x.Key)) |
| 497 | { |
| 498 | var n = kv.Value.Items.Count; |
| 499 | sb.AppendLine(" <li>"); |
| 500 | sb.AppendLine($" <a href=\"{tagsBase}{kv.Key}.html\">{WebUtility.HtmlEncode(kv.Value.Display)}</a>"); |
| 501 | sb.AppendLine($" <span class=\"tags-index-count\">({n})</span>"); |
| 502 | sb.AppendLine(" </li>"); |
| 503 | } |
| 504 | sb.AppendLine(" </ul>"); |
| 505 | sb.AppendLine(" </div>"); |
| 506 | sb.AppendLine(" </main>"); |
| 507 | sb.AppendLine(); |
| 508 | sb.AppendLine(" <footer id=\"contact\">"); |
| 509 | sb.AppendLine(" <div class=\"container\">"); |
| 510 | sb.AppendLine(" <div class=\"contact-links\">"); |
| 511 | sb.AppendLine($" <a href=\"https://t.me/Krawler\">{labels.Telegram}</a>"); |
| 512 | sb.AppendLine($" <a href=\"https://github.com/AI-Guiders\">{labels.GitHub}</a>"); |
| 513 | sb.AppendLine(" </div>"); |
| 514 | sb.AppendLine($" <p>{siteName} © 2026.</p>"); |
| 515 | sb.AppendLine(" </div>"); |
| 516 | sb.AppendLine(" </footer>"); |
| 517 | sb.AppendLine("</body>"); |
| 518 | sb.AppendLine("</html>"); |
| 519 | |
| 520 | File.WriteAllText(Path.Combine(outDir, "tags.html"), sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); |
| 521 | } |
| 522 | |
| 523 | static void WriteTagPage(string slug, string display, List<Article> items, string lang, string tagOutDir, string writingBase) |
| 524 | { |
| 525 | var isEn = lang == "en"; |
| 526 | var labels = isEn |
| 527 | ? new NavLabels("Skip to content", "Main navigation", "Language", "About", "Open stack", "Writing", "Handbook", "GitHub", "All writing", "Email", "Telegram", "GitHub", "All tags") |
| 528 | : new NavLabels("К содержанию", "Основная навигация", "Язык", "О нас", "Open stack", "Тексты", "Handbook", "GitHub", "Все тексты", "Email", "Telegram", "GitHub", "Все теги"); |
| 529 | |
| 530 | var home = isEn ? "/" : "/ru/"; |
| 531 | var logo = isEn ? "AI<span>-</span>Guiders" : "AI<span>-</span>Guiders"; |
| 532 | var siteName = isEn ? "AI-Guiders" : "AI-Guiders"; |
| 533 | var pageTitle = (isEn ? "Tag: " : "Тег: ") + display + (isEn ? " — AI-Guiders" : " — AI-Guiders"); |
| 534 | var h1 = (isEn ? "Tag: " : "Тег: ") + display; |
| 535 | var back = isEn ? "Writing" : "Тексты"; |
| 536 | |
| 537 | var sb = new StringBuilder(); |
| 538 | sb.AppendLine("<!DOCTYPE html>"); |
| 539 | sb.AppendLine($"<html lang=\"{lang}\">"); |
| 540 | sb.AppendLine("<head>"); |
| 541 | sb.AppendLine(" <meta charset=\"utf-8\" />"); |
| 542 | sb.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />"); |
| 543 | sb.AppendLine($" <title>{WebUtility.HtmlEncode(pageTitle)}</title>"); |
| 544 | sb.AppendLine(" <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>\" />"); |
| 545 | sb.AppendLine(" <link rel=\"stylesheet\" href=\"/assets/css/articles.css\" />"); |
| 546 | sb.AppendLine($" <link rel=\"alternate\" type=\"application/atom+xml\" title=\"{(isEn ? "Writing (Atom)" : "Тексты (Atom)")}\" href=\"{(isEn ? "/writing/atom.xml" : "/ru/writing/atom.xml")}\" />"); |
| 547 | sb.AppendLine("</head>"); |
| 548 | sb.AppendLine("<body>"); |
| 549 | sb.AppendLine($" <a href=\"#main\" class=\"skip-link\">{labels.SkipToContent}</a>"); |
| 550 | sb.AppendLine(); |
| 551 | sb.AppendLine($" <nav aria-label=\"{WebUtility.HtmlEncode(labels.NavMain)}\">"); |
| 552 | sb.AppendLine(" <div class=\"nav-inner\">"); |
| 553 | sb.AppendLine($" <div class=\"logo\"><a href=\"{home}\">{logo}</a></div>"); |
| 554 | sb.AppendLine(" <div class=\"nav-right\">"); |
| 555 | sb.AppendLine(" <ul>"); |
| 556 | sb.AppendLine($" <li><a href=\"{home}#about\">{labels.About}</a></li>"); |
| 557 | sb.AppendLine($" <li><a href=\"{home}#stack\">{labels.Projects}</a></li>"); |
| 558 | sb.AppendLine($" <li><a href=\"{writingBase}\" aria-current=\"page\">{labels.Writing}</a></li>"); |
| 559 | sb.AppendLine($" <li><a href=\"https://github.com/AI-Guiders/handbook/wiki\">{labels.Experience}</a></li>"); |
| 560 | sb.AppendLine($" <li><a href=\"https://github.com/AI-Guiders\">{labels.Docs}</a></li>"); |
| 561 | sb.AppendLine(" </ul>"); |
| 562 | sb.AppendLine($" <ul class=\"nav-lang\" aria-label=\"{WebUtility.HtmlEncode(labels.Language)}\">"); |
| 563 | sb.AppendLine($" <li><a href=\"/writing/tag/{slug}.html\" lang=\"en\"{(isEn ? " aria-current=\"page\"" : "")}>EN</a></li>"); |
| 564 | sb.AppendLine(" <li class=\"nav-sep\" aria-hidden=\"true\">|</li>"); |
| 565 | sb.AppendLine($" <li><a href=\"/ru/writing/tag/{slug}.html\" lang=\"ru\"{(!isEn ? " aria-current=\"page\"" : "")}>RU</a></li>"); |
| 566 | sb.AppendLine(" </ul>"); |
| 567 | sb.AppendLine(" </div>"); |
| 568 | sb.AppendLine(" </div>"); |
| 569 | sb.AppendLine(" </nav>"); |
| 570 | sb.AppendLine(); |
| 571 | sb.AppendLine(" <main id=\"main\" class=\"container\">"); |
| 572 | sb.AppendLine(" <div class=\"article-wrap\">"); |
| 573 | sb.AppendLine($" <p class=\"article-back\"><a href=\"{writingBase}\">← {back}</a> · <a href=\"{writingBase}tags.html\">{labels.AllTags}</a> · <a href=\"{writingBase}atom.xml\">{(isEn ? "Atom feed" : "Лента Atom")}</a></p>"); |
| 574 | sb.AppendLine($" <h1 class=\"page-title\">{WebUtility.HtmlEncode(h1)}</h1>"); |
| 575 | sb.AppendLine(" <ul class=\"writing-list\">"); |
| 576 | foreach (var a in items) |
| 577 | { |
| 578 | var href = writingBase.TrimEnd('/') + "/" + a.Slug + ".html"; |
| 579 | sb.AppendLine(" <li>"); |
| 580 | sb.AppendLine($" <a class=\"title\" href=\"{href}\">{WebUtility.HtmlEncode(a.Title)}</a>"); |
| 581 | sb.AppendLine($" <p class=\"blurb\">{WebUtility.HtmlEncode(a.Description)}</p>"); |
| 582 | sb.AppendLine(" </li>"); |
| 583 | } |
| 584 | sb.AppendLine(" </ul>"); |
| 585 | sb.AppendLine(" </div>"); |
| 586 | sb.AppendLine(" </main>"); |
| 587 | sb.AppendLine(); |
| 588 | sb.AppendLine(" <footer id=\"contact\">"); |
| 589 | sb.AppendLine(" <div class=\"container\">"); |
| 590 | sb.AppendLine(" <div class=\"contact-links\">"); |
| 591 | sb.AppendLine($" <a href=\"https://t.me/Krawler\">{labels.Telegram}</a>"); |
| 592 | sb.AppendLine($" <a href=\"https://github.com/AI-Guiders\">{labels.GitHub}</a>"); |
| 593 | sb.AppendLine(" </div>"); |
| 594 | sb.AppendLine($" <p>{siteName} © 2026.</p>"); |
| 595 | sb.AppendLine(" </div>"); |
| 596 | sb.AppendLine(" </footer>"); |
| 597 | sb.AppendLine("</body>"); |
| 598 | sb.AppendLine("</html>"); |
| 599 | |
| 600 | File.WriteAllText(Path.Combine(tagOutDir, slug + ".html"), sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); |
| 601 | } |
| 602 | |
| 603 | static void WriteAtomFeed(List<Article> articles, string lang, string outDir, string writingBase) |
| 604 | { |
| 605 | const string siteOrigin = "https://ai-guiders.github.io"; |
| 606 | var isEn = lang == "en"; |
| 607 | var feedTitle = isEn ? "Writing — AI-Guiders" : "Тексты — AI-Guiders"; |
| 608 | var selfUrl = siteOrigin + (isEn ? "/writing/atom.xml" : "/ru/writing/atom.xml"); |
| 609 | var htmlBase = siteOrigin + writingBase.TrimEnd('/') + "/"; |
| 610 | var sorted = articles.OrderByDescending(x => x.Order).ThenBy(x => x.Slug).ToList(); |
| 611 | var feedUpdated = new DateTimeOffset(2026, 4, 1, 12, 0, 0, TimeSpan.Zero); |
| 612 | foreach (var a in sorted) |
| 613 | { |
| 614 | var u = AtomDate(a); |
| 615 | if (u > feedUpdated) |
| 616 | feedUpdated = u; |
| 617 | } |
| 618 | |
| 619 | var sb = new StringBuilder(); |
| 620 | sb.AppendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>"); |
| 621 | sb.AppendLine("<feed xmlns=\"http://www.w3.org/2005/Atom\">"); |
| 622 | sb.AppendLine($" <title>{XmlText(feedTitle)}</title>"); |
| 623 | sb.AppendLine($" <link href=\"{htmlBase}\" rel=\"alternate\" type=\"text/html\" />"); |
| 624 | sb.AppendLine($" <link href=\"{selfUrl}\" rel=\"self\" type=\"application/atom+xml\" />"); |
| 625 | sb.AppendLine($" <id>{selfUrl}</id>"); |
| 626 | sb.AppendLine($" <updated>{feedUpdated:yyyy-MM-ddTHH:mm:ssZ}</updated>"); |
| 627 | sb.AppendLine(" <author><name>AI-Guiders</name></author>"); |
| 628 | |
| 629 | foreach (var a in sorted) |
| 630 | { |
| 631 | var updated = AtomDate(a); |
| 632 | var entryUrl = siteOrigin + writingBase.TrimEnd('/') + "/" + a.Slug + ".html"; |
| 633 | sb.AppendLine(" <entry>"); |
| 634 | sb.AppendLine($" <title>{XmlText(a.Title)}</title>"); |
| 635 | sb.AppendLine($" <link href=\"{entryUrl}\" rel=\"alternate\" type=\"text/html\" />"); |
| 636 | sb.AppendLine($" <id>{entryUrl}</id>"); |
| 637 | sb.AppendLine($" <updated>{updated:yyyy-MM-ddTHH:mm:ssZ}</updated>"); |
| 638 | sb.AppendLine($" <summary type=\"text\">{XmlText(a.Description)}</summary>"); |
| 639 | sb.AppendLine(" </entry>"); |
| 640 | } |
| 641 | |
| 642 | sb.AppendLine("</feed>"); |
| 643 | File.WriteAllText(Path.Combine(outDir, "atom.xml"), sb.ToString(), new UTF8Encoding(false)); |
| 644 | } |
| 645 | |
| 646 | static DateTimeOffset AtomDate(Article a) => |
| 647 | new DateTimeOffset(2026, 4, 1, 12, 0, 0, TimeSpan.Zero).AddDays(a.Order); |
| 648 | |
| 649 | static string XmlText(string s) |
| 650 | { |
| 651 | if (string.IsNullOrEmpty(s)) return ""; |
| 652 | return s.Replace("&", "&").Replace("<", "<").Replace(">", ">"); |
| 653 | } |
| 654 | |
| 655 | static string Indent(string html, string prefix) |
| 656 | { |
| 657 | var lines = html.Split('\n'); |
| 658 | var sb = new StringBuilder(); |
| 659 | foreach (var line in lines) |
| 660 | { |
| 661 | if (sb.Length > 0) sb.AppendLine(); |
| 662 | sb.Append(string.IsNullOrWhiteSpace(line) ? line : prefix + line); |
| 663 | } |
| 664 | return sb.ToString(); |
| 665 | } |
| 666 | |
| 667 | static string BuildProvenanceAside(CoauthorshipProvenance p, bool isEn) |
| 668 | { |
| 669 | var summary = isEn ? "How this piece was written" : "Как написан этот текст"; |
| 670 | var lblAuthor = isEn ? "Author" : "Автор"; |
| 671 | var lblCoauthors = isEn ? "Co-authors (tools / models)" : "Соавторы (инструменты / модели)"; |
| 672 | var lblContribution = isEn ? "Contribution" : "Вклад"; |
| 673 | var lblHumanFinal = isEn ? "Final review and responsibility" : "Финальная проверка и ответственность"; |
| 674 | |
| 675 | var sb = new StringBuilder(); |
| 676 | sb.AppendLine("<aside class=\"article-provenance\" aria-label=\"" + WebUtility.HtmlEncode(summary) + "\">"); |
| 677 | sb.AppendLine(" <details class=\"article-provenance-details\">"); |
| 678 | sb.AppendLine(" <summary class=\"article-provenance-summary\">" + WebUtility.HtmlEncode(summary) + "</summary>"); |
| 679 | sb.AppendLine(" <dl class=\"article-provenance-dl\">"); |
| 680 | if (p.Author is { } au) |
| 681 | { |
| 682 | sb.AppendLine(" <dt>" + WebUtility.HtmlEncode(lblAuthor) + "</dt>"); |
| 683 | sb.AppendLine(" <dd>" + WebUtility.HtmlEncode(au) + "</dd>"); |
| 684 | } |
| 685 | if (p.Coauthors.Count > 0) |
| 686 | { |
| 687 | sb.AppendLine(" <dt>" + WebUtility.HtmlEncode(lblCoauthors) + "</dt>"); |
| 688 | sb.AppendLine(" <dd><ul class=\"article-provenance-list\">"); |
| 689 | foreach (var c in p.Coauthors) |
| 690 | sb.AppendLine(" <li>" + WebUtility.HtmlEncode(c) + "</li>"); |
| 691 | sb.AppendLine(" </ul></dd>"); |
| 692 | } |
| 693 | if (p.Contribution is { } ct) |
| 694 | { |
| 695 | sb.AppendLine(" <dt>" + WebUtility.HtmlEncode(lblContribution) + "</dt>"); |
| 696 | sb.AppendLine(" <dd class=\"article-provenance-multiline\">" + WebUtility.HtmlEncode(ct) + "</dd>"); |
| 697 | } |
| 698 | if (p.HumanFinal is { } hf) |
| 699 | { |
| 700 | sb.AppendLine(" <dt>" + WebUtility.HtmlEncode(lblHumanFinal) + "</dt>"); |
| 701 | sb.AppendLine(" <dd class=\"article-provenance-multiline\">" + WebUtility.HtmlEncode(hf) + "</dd>"); |
| 702 | } |
| 703 | sb.AppendLine(" </dl>"); |
| 704 | sb.AppendLine(" </details>"); |
| 705 | sb.AppendLine("</aside>"); |
| 706 | return sb.ToString().TrimEnd(); |
| 707 | } |
| 708 | |
| 709 | record ArticleTag(string Display, string Slug); |
| 710 | |
| 711 | record Article(string Slug, string Title, string Description, string DateDisplay, int Order, string BodyHtml, IReadOnlyList<ArticleTag> Tags, CoauthorshipProvenance? Provenance); |
| 712 | |
| 713 | record CoauthorshipProvenance(string? Author, IReadOnlyList<string> Coauthors, string? Contribution, string? HumanFinal); |
| 714 | |
| 715 | class FrontMatter |
| 716 | { |
| 717 | public string Slug { get; set; } = ""; |
| 718 | public string Title { get; set; } = ""; |
| 719 | public string Description { get; set; } = ""; |
| 720 | public string Date_display { get; set; } = ""; |
| 721 | public int Order { get; set; } = 100; |
| 722 | [YamlMember(Alias = "tags")] |
| 723 | public List<string> Tags { get; set; } = new(); |
| 724 | |
| 725 | /// <summary>When true and at least one provenance field is set, a disclosure block is rendered. Omit or false: no block (default).</summary> |
| 726 | public bool Show_provenance { get; set; } |
| 727 | |
| 728 | public string? Provenance_author { get; set; } |
| 729 | public List<string>? Provenance_coauthors { get; set; } |
| 730 | public string? Provenance_contribution { get; set; } |
| 731 | public string? Provenance_human_final { get; set; } |
| 732 | } |
| 733 | |
| 734 | record NavLabels( |
| 735 | string SkipToContent, |
| 736 | string NavMain, |
| 737 | string Language, |
| 738 | string About, |
| 739 | string Projects, |
| 740 | string Writing, |
| 741 | string Experience, |
| 742 | string Docs, |
| 743 | string AllWriting, |
| 744 | string Email, |
| 745 | string Telegram, |
| 746 | string GitHub, |
| 747 | string AllTags); |
| 748 | |