Forge
0d405cb9
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
7using System.Linq;
8using System.Net;
9using System.Text;
10using System.Text.RegularExpressions;
11using Markdig;
12using YamlDotNet.Serialization;
13using YamlDotNet.Serialization.NamingConventions;
14
15var repoRoot = Args.Count > 0 && !string.IsNullOrWhiteSpace(Args[0])
16 ? Path.GetFullPath(Args[0])
17 : Directory.GetCurrentDirectory();
18
19var srcEn = Path.Combine(repoRoot, "src", "writing", "en");
20var srcRu = Path.Combine(repoRoot, "src", "writing", "ru");
21var docsOut = Path.Combine(repoRoot, "docs");
22var writingEnOut = Path.Combine(docsOut, "writing");
23var writingRuOut = Path.Combine(docsOut, "ru", "writing");
24
25if (!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
31Directory.CreateDirectory(writingEnOut);
32Directory.CreateDirectory(writingRuOut);
33
34var mdPipeline = new MarkdownPipelineBuilder()
35 .UseAdvancedExtensions()
36 .Build();
37
38var yaml = new DeserializerBuilder()
39 .WithNamingConvention(UnderscoredNamingConvention.Instance)
40 .IgnoreUnmatchedProperties()
41 .Build();
42
43var enArticles = LoadArticles(srcEn, yaml, mdPipeline);
44var ruArticles = LoadArticles(srcRu, yaml, mdPipeline);
45
46foreach (var a in enArticles)
47 WriteArticlePage(a, "en", writingEnOut, "/writing/");
48foreach (var a in ruArticles)
49 WriteArticlePage(a, "ru", writingRuOut, "/ru/writing/");
50
51WriteIndex(enArticles.OrderBy(x => x.Order).ThenBy(x => x.Slug).ToList(), "en", writingEnOut);
52WriteIndex(ruArticles.OrderBy(x => x.Order).ThenBy(x => x.Slug).ToList(), "ru", writingRuOut);
53
54WriteTagDirectory(enArticles, "en", writingEnOut, "/writing/");
55WriteTagDirectory(ruArticles, "ru", writingRuOut, "/ru/writing/");
56
57WriteAtomFeed(enArticles, "en", writingEnOut, "/writing/");
58WriteAtomFeed(ruArticles, "ru", writingRuOut, "/ru/writing/");
59
60Console.WriteLine($"Writing: {enArticles.Count} EN + {ruArticles.Count} RU articles → docs/writing/ (+ tags, atom)");
61
62static 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
81static 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
100static 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>
109static 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
150static 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
170static (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
182static 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'>&#x1F916;</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}\">&larr; {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} &copy; 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
282static 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 &ldquo;parity&rdquo; 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'>&#x1F916;</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}\">&larr; {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} &copy; 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
408static 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
431static 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'>&#x1F916;</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}\">&larr; {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} &copy; 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
523static 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'>&#x1F916;</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}\">&larr; {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} &copy; 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
603static 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
646static DateTimeOffset AtomDate(Article a) =>
647 new DateTimeOffset(2026, 4, 1, 12, 0, 0, TimeSpan.Zero).AddDays(a.Order);
648
649static string XmlText(string s)
650{
651 if (string.IsNullOrEmpty(s)) return "";
652 return s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
653}
654
655static 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
667static 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
709record ArticleTag(string Display, string Slug);
710
711record Article(string Slug, string Title, string Description, string DateDisplay, int Order, string BodyHtml, IReadOnlyList<ArticleTag> Tags, CoauthorshipProvenance? Provenance);
712
713record CoauthorshipProvenance(string? Author, IReadOnlyList<string> Coauthors, string? Contribution, string? HumanFinal);
714
715class 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
734record 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
View only · write via MCP/CIDE