| 1 | #!/usr/bin/env dotnet script |
| 2 | // Сборка всех нумерованных ADR в один документ (как resume: фрагменты через INCLUDE). |
| 3 | // Запуск из каталога docs/adr: |
| 4 | // dotnet script build-adr.csx |
| 5 | // dotnet script build-adr.csx --root "D:\path\to\docs\adr" |
| 6 | // Нужно: dotnet tool install -g dotnet-script, Pandoc; PDF без LaTeX — Microsoft Edge. |
| 7 | // |
| 8 | // Директивы в .md (тот же контракт, что resume/build-resume.csx): |
| 9 | // {{ INCLUDE: путь/от/корня/adr.md }} |
| 10 | // {{ INCLUDE_MANIFEST: build/order.txt }} |
| 11 | // {{ INCLUDE_GLOB: snippets/*.md }} |
| 12 | // |
| 13 | // Режимы: |
| 14 | // • Если есть adr-book.md — он корень (YAML + INCLUDE*), как resume.md. |
| 15 | // • Иначе — склеиваются все файлы ^\d{4}-.+\.md$ в docs/adr по имени, между ними ---; затем ExpandIncludes. |
| 16 | // Опция: --book adr-book-ui.md — другой корневой файл и префикс артефактов (adr-book-ui.html и т.д.). |
| 17 | |
| 18 | using System; |
| 19 | using System.Collections.Generic; |
| 20 | using System.Diagnostics; |
| 21 | using System.IO; |
| 22 | using System.Linq; |
| 23 | using System.Text; |
| 24 | using System.Text.RegularExpressions; |
| 25 | |
| 26 | var adrRoot = ResolveAdrRoot(); |
| 27 | |
| 28 | string bookArg = null; |
| 29 | var argvForBook = Environment.GetCommandLineArgs(); |
| 30 | for (var bi = 0; bi < argvForBook.Length - 1; bi++) |
| 31 | if (string.Equals(argvForBook[bi], "--book", StringComparison.OrdinalIgnoreCase)) |
| 32 | { |
| 33 | bookArg = argvForBook[bi + 1]; |
| 34 | break; |
| 35 | } |
| 36 | |
| 37 | var bookRel = string.IsNullOrWhiteSpace(bookArg) ? "adr-book.md" : bookArg.Trim(); |
| 38 | var baseName = Path.GetFileNameWithoutExtension(bookRel); |
| 39 | if (string.IsNullOrEmpty(baseName)) baseName = "adr-book"; |
| 40 | var bookPath = Path.Combine(adrRoot, bookRel.Replace('/', Path.DirectorySeparatorChar)); |
| 41 | |
| 42 | var buildDir = Path.Combine(adrRoot, "build"); |
| 43 | var outDir = Path.Combine(adrRoot, "out"); |
| 44 | var outHtmlDir = Path.Combine(outDir, "html"); |
| 45 | var outPdfDir = Path.Combine(outDir, "pdf"); |
| 46 | var outTxtDir = Path.Combine(outDir, "txt"); |
| 47 | var utf8NoBom = new UTF8Encoding(false); |
| 48 | |
| 49 | string ResolveAdrRoot() |
| 50 | { |
| 51 | var argv = Environment.GetCommandLineArgs(); |
| 52 | for (var i = 0; i < argv.Length - 1; i++) |
| 53 | if (string.Equals(argv[i], "--root", StringComparison.OrdinalIgnoreCase)) |
| 54 | return Path.GetFullPath(argv[i + 1]); |
| 55 | return Directory.GetCurrentDirectory(); |
| 56 | } |
| 57 | |
| 58 | string ExpandManifest(string manifestRel, string baseDir) |
| 59 | { |
| 60 | var manifestPath = Path.Combine(baseDir, manifestRel.Replace('/', Path.DirectorySeparatorChar)); |
| 61 | if (!File.Exists(manifestPath)) |
| 62 | { |
| 63 | Console.WriteLine($"Warning: INCLUDE_MANIFEST not found: {manifestPath}"); |
| 64 | return ""; |
| 65 | } |
| 66 | var sb = new StringBuilder(); |
| 67 | foreach (var line in File.ReadAllLines(manifestPath, Encoding.UTF8)) |
| 68 | { |
| 69 | var t = line.Trim(); |
| 70 | if (t.Length == 0 || t.StartsWith("#", StringComparison.Ordinal)) continue; |
| 71 | var p = Path.Combine(baseDir, t.Replace('/', Path.DirectorySeparatorChar)); |
| 72 | if (!File.Exists(p)) |
| 73 | { |
| 74 | Console.WriteLine($"Warning: manifest line file missing: {p}"); |
| 75 | continue; |
| 76 | } |
| 77 | if (sb.Length > 0) sb.AppendLine(); |
| 78 | sb.Append(File.ReadAllText(p, Encoding.UTF8)); |
| 79 | } |
| 80 | return sb.ToString(); |
| 81 | } |
| 82 | |
| 83 | string ExpandGlob(string globRel, string baseDir) |
| 84 | { |
| 85 | var g = globRel.Replace('/', Path.DirectorySeparatorChar).Trim(); |
| 86 | var star = g.IndexOf('*'); |
| 87 | if (star < 0) |
| 88 | { |
| 89 | Console.WriteLine("Warning: INCLUDE_GLOB needs a * pattern (e.g. snippets/*.md)"); |
| 90 | return ""; |
| 91 | } |
| 92 | var beforeStar = g.Substring(0, star); |
| 93 | var lastSep = Math.Max(beforeStar.LastIndexOf(Path.DirectorySeparatorChar), beforeStar.LastIndexOf('/')); |
| 94 | var relDir = lastSep >= 0 ? beforeStar.Substring(0, lastSep) : ""; |
| 95 | var pattern = g.Substring(star); |
| 96 | var dir = Path.Combine(baseDir, relDir); |
| 97 | if (!Directory.Exists(dir)) |
| 98 | { |
| 99 | Console.WriteLine($"Warning: INCLUDE_GLOB directory not found: {dir}"); |
| 100 | return ""; |
| 101 | } |
| 102 | var files = Directory.GetFiles(dir, pattern, SearchOption.TopDirectoryOnly) |
| 103 | .Where(f => !Path.GetFileName(f).StartsWith("_", StringComparison.Ordinal)) |
| 104 | .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) |
| 105 | .ToArray(); |
| 106 | var sb = new StringBuilder(); |
| 107 | foreach (var f in files) |
| 108 | { |
| 109 | if (sb.Length > 0) sb.AppendLine(); |
| 110 | sb.Append(File.ReadAllText(f, Encoding.UTF8)); |
| 111 | } |
| 112 | return sb.ToString(); |
| 113 | } |
| 114 | |
| 115 | // Fenced ``` … ``` → токены, чтобы примеры {{ INCLUDE: … }} в кодовых блоках не раскрывались. |
| 116 | (string Masked, Dictionary<string, string> FenceMap) MaskFencedBlocks(string content) |
| 117 | { |
| 118 | var map = new Dictionary<string, string>(); |
| 119 | var sb = new StringBuilder(); |
| 120 | var pos = 0; |
| 121 | var n = 0; |
| 122 | while (pos < content.Length) |
| 123 | { |
| 124 | var start = content.IndexOf("```", pos, StringComparison.Ordinal); |
| 125 | if (start < 0) |
| 126 | { |
| 127 | sb.Append(content, pos, content.Length - pos); |
| 128 | break; |
| 129 | } |
| 130 | sb.Append(content, pos, start - pos); |
| 131 | var lineEnd = content.IndexOf('\n', start); |
| 132 | if (lineEnd < 0) |
| 133 | { |
| 134 | sb.Append(content, start, content.Length - start); |
| 135 | break; |
| 136 | } |
| 137 | var contentStart = lineEnd + 1; |
| 138 | var closeLineStart = -1; |
| 139 | var scan = contentStart; |
| 140 | while (scan < content.Length) |
| 141 | { |
| 142 | var nl = content.IndexOf('\n', scan); |
| 143 | if (nl < 0) |
| 144 | break; |
| 145 | var lineBegin = nl + 1; |
| 146 | if (lineBegin + 3 <= content.Length && content[lineBegin] == '`' && content[lineBegin + 1] == '`' && content[lineBegin + 2] == '`') |
| 147 | { |
| 148 | var afterTicks = lineBegin + 3; |
| 149 | if (afterTicks >= content.Length || content[afterTicks] == '\r' || content[afterTicks] == '\n') |
| 150 | { |
| 151 | closeLineStart = lineBegin; |
| 152 | break; |
| 153 | } |
| 154 | } |
| 155 | scan = nl + 1; |
| 156 | } |
| 157 | if (closeLineStart < 0) |
| 158 | { |
| 159 | sb.Append(content, start, content.Length - start); |
| 160 | break; |
| 161 | } |
| 162 | var blockEnd = closeLineStart + 3; |
| 163 | while (blockEnd < content.Length && (content[blockEnd] == '\r' || content[blockEnd] == '\n')) |
| 164 | blockEnd++; |
| 165 | var full = content.Substring(start, blockEnd - start); |
| 166 | var token = $"\uE000FENCE{n++}\uE000"; |
| 167 | map[token] = full; |
| 168 | sb.Append(token); |
| 169 | pos = blockEnd; |
| 170 | } |
| 171 | return (sb.ToString(), map); |
| 172 | } |
| 173 | |
| 174 | string UnmaskFencedBlocks(string content, Dictionary<string, string> map) |
| 175 | { |
| 176 | foreach (var kv in map) |
| 177 | content = content.Replace(kv.Key, kv.Value, StringComparison.Ordinal); |
| 178 | return content; |
| 179 | } |
| 180 | |
| 181 | string ExpandIncludes(string content, string baseDir) |
| 182 | { |
| 183 | var (masked, fenceMap) = MaskFencedBlocks(content); |
| 184 | content = masked; |
| 185 | |
| 186 | var rxManifest = new Regex(@"\{\{\s*INCLUDE_MANIFEST:\s*([^\s\}]+)\s*\}\}"); |
| 187 | var rxGlob = new Regex(@"\{\{\s*INCLUDE_GLOB:\s*([^\s\}]+)\s*\}\}"); |
| 188 | var rxSingle = new Regex(@"\{\{\s*INCLUDE:\s*([^\s\}]+)\s*\}\}"); |
| 189 | |
| 190 | while (true) |
| 191 | { |
| 192 | var mMan = rxManifest.Match(content); |
| 193 | var mGlob = rxGlob.Match(content); |
| 194 | var mOne = rxSingle.Match(content); |
| 195 | |
| 196 | var candidates = new List<(int Index, int Kind, Match M)>(); |
| 197 | if (mMan.Success) candidates.Add((mMan.Index, 0, mMan)); |
| 198 | if (mGlob.Success) candidates.Add((mGlob.Index, 1, mGlob)); |
| 199 | if (mOne.Success) candidates.Add((mOne.Index, 2, mOne)); |
| 200 | if (candidates.Count == 0) break; |
| 201 | |
| 202 | var pick = candidates.OrderBy(c => c.Index).First(); |
| 203 | var m = pick.M; |
| 204 | string replacement; |
| 205 | switch (pick.Kind) |
| 206 | { |
| 207 | case 0: |
| 208 | replacement = ExpandManifest(m.Groups[1].Value.Trim(), baseDir); |
| 209 | break; |
| 210 | case 1: |
| 211 | replacement = ExpandGlob(m.Groups[1].Value.Trim(), baseDir); |
| 212 | break; |
| 213 | default: |
| 214 | var includePath = Path.Combine(baseDir, m.Groups[1].Value.Trim().Replace('/', Path.DirectorySeparatorChar)); |
| 215 | if (!File.Exists(includePath)) |
| 216 | { |
| 217 | Console.WriteLine($"Warning: INCLUDE not found: {includePath}"); |
| 218 | replacement = ""; |
| 219 | } |
| 220 | else |
| 221 | replacement = File.ReadAllText(includePath, Encoding.UTF8); |
| 222 | break; |
| 223 | } |
| 224 | |
| 225 | content = content.Substring(0, m.Index) + replacement + content.Substring(m.Index + m.Length); |
| 226 | } |
| 227 | |
| 228 | return UnmaskFencedBlocks(content, fenceMap); |
| 229 | } |
| 230 | |
| 231 | int Run(string executable, string args, string workingDirectory = null) |
| 232 | { |
| 233 | try |
| 234 | { |
| 235 | var psi = new ProcessStartInfo |
| 236 | { |
| 237 | FileName = executable, |
| 238 | Arguments = args, |
| 239 | UseShellExecute = false, |
| 240 | CreateNoWindow = true, |
| 241 | RedirectStandardOutput = true, |
| 242 | RedirectStandardError = true |
| 243 | }; |
| 244 | if (!string.IsNullOrEmpty(workingDirectory)) |
| 245 | psi.WorkingDirectory = workingDirectory; |
| 246 | using var p = Process.Start(psi); |
| 247 | var stdout = p!.StandardOutput.ReadToEnd(); |
| 248 | var stderr = p.StandardError.ReadToEnd(); |
| 249 | p.WaitForExit(); |
| 250 | |
| 251 | if (p.ExitCode != 0) |
| 252 | { |
| 253 | Console.WriteLine($"Error: command failed (exit={p.ExitCode}): {executable} {args}"); |
| 254 | if (!string.IsNullOrWhiteSpace(stdout)) |
| 255 | Console.WriteLine(stdout.TrimEnd()); |
| 256 | if (!string.IsNullOrWhiteSpace(stderr)) |
| 257 | Console.WriteLine(stderr.TrimEnd()); |
| 258 | } |
| 259 | |
| 260 | return p.ExitCode; |
| 261 | } |
| 262 | catch (Exception ex) |
| 263 | { |
| 264 | Console.WriteLine($"Error: {ex.Message}"); |
| 265 | return -1; |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | void TryInjectMermaidScript(string htmlPath) |
| 270 | { |
| 271 | try |
| 272 | { |
| 273 | if (string.IsNullOrEmpty(htmlPath) || !File.Exists(htmlPath)) return; |
| 274 | var html = File.ReadAllText(htmlPath, Encoding.UTF8); |
| 275 | if (!html.Contains("class=\"mermaid\"", StringComparison.Ordinal)) return; |
| 276 | if (html.Contains("mermaid.min.js", StringComparison.OrdinalIgnoreCase)) return; |
| 277 | if (!html.Contains("</body>", StringComparison.OrdinalIgnoreCase)) return; |
| 278 | const string snippet = "\n<script src=\"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\"></script>\n<script>\n mermaid.initialize({ startOnLoad: true, theme: \"neutral\", securityLevel: \"loose\" });\n</script>\n"; |
| 279 | html = html.Replace("</body>", snippet + "</body>", StringComparison.OrdinalIgnoreCase); |
| 280 | File.WriteAllText(htmlPath, html, new UTF8Encoding(false)); |
| 281 | } |
| 282 | catch (Exception ex) |
| 283 | { |
| 284 | Console.WriteLine($"Warning: Mermaid inject skipped: {ex.Message}"); |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | string FindEdge() |
| 289 | { |
| 290 | var x86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); |
| 291 | var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); |
| 292 | foreach (var p in new[] |
| 293 | { |
| 294 | Path.Combine(x86 ?? "", "Microsoft", "Edge", "Application", "msedge.exe"), |
| 295 | Path.Combine(pf, "Microsoft", "Edge", "Application", "msedge.exe") |
| 296 | }) |
| 297 | if (File.Exists(p)) return p; |
| 298 | return null; |
| 299 | } |
| 300 | |
| 301 | string FindInPath(string name) |
| 302 | { |
| 303 | if (string.IsNullOrEmpty(Path.GetExtension(name)) && Environment.OSVersion.Platform == PlatformID.Win32NT) |
| 304 | name += ".exe"; |
| 305 | var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; |
| 306 | foreach (var dir in pathEnv.Split(Path.PathSeparator)) |
| 307 | { |
| 308 | var full = Path.Combine(dir.Trim(), name); |
| 309 | if (File.Exists(full)) return full; |
| 310 | } |
| 311 | return null; |
| 312 | } |
| 313 | |
| 314 | string BuildAutoConcatBody() |
| 315 | { |
| 316 | var rx = new Regex(@"^\d{4}-.+\.md$", RegexOptions.IgnoreCase); |
| 317 | var files = Directory.GetFiles(adrRoot, "*.md", SearchOption.TopDirectoryOnly) |
| 318 | .Where(f => rx.IsMatch(Path.GetFileName(f))) |
| 319 | .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) |
| 320 | .ToArray(); |
| 321 | if (files.Length == 0) |
| 322 | { |
| 323 | Console.WriteLine("No numbered ADR files (NNNN-*.md) in " + adrRoot); |
| 324 | return ""; |
| 325 | } |
| 326 | Console.WriteLine($"Auto-concat: {files.Length} ADR file(s)."); |
| 327 | var sb = new StringBuilder(); |
| 328 | sb.AppendLine("---"); |
| 329 | sb.AppendLine($"title: \"Cascade IDE — Architecture Decision Records\""); |
| 330 | sb.AppendLine($"date: {DateTime.UtcNow:yyyy-MM-dd}"); |
| 331 | sb.AppendLine("lang: ru"); |
| 332 | sb.AppendLine("---"); |
| 333 | sb.AppendLine(); |
| 334 | sb.AppendLine("*Сборка: все файлы `NNNN-*.md` в каталоге `docs/adr`, порядок по имени. DOCX не генерируется по политике сборки.*"); |
| 335 | sb.AppendLine(); |
| 336 | for (var i = 0; i < files.Length; i++) |
| 337 | { |
| 338 | if (i > 0) sb.AppendLine().AppendLine("---").AppendLine(); |
| 339 | sb.Append(File.ReadAllText(files[i], Encoding.UTF8)); |
| 340 | } |
| 341 | return sb.ToString(); |
| 342 | } |
| 343 | |
| 344 | var pandocPath = FindInPath("pandoc"); |
| 345 | if (string.IsNullOrEmpty(pandocPath)) |
| 346 | { |
| 347 | Console.WriteLine("Pandoc not found. Install: winget install JohnMacFarlane.Pandoc"); |
| 348 | return 1; |
| 349 | } |
| 350 | |
| 351 | string mdContent; |
| 352 | if (!string.IsNullOrWhiteSpace(bookArg) && !File.Exists(bookPath)) |
| 353 | { |
| 354 | Console.WriteLine($"--book file not found: {bookPath}"); |
| 355 | return 1; |
| 356 | } |
| 357 | if (File.Exists(bookPath)) |
| 358 | { |
| 359 | Console.WriteLine($"Source: {bookRel}"); |
| 360 | mdContent = File.ReadAllText(bookPath, Encoding.UTF8); |
| 361 | } |
| 362 | else |
| 363 | { |
| 364 | Console.WriteLine("Source: auto-concat of NNNN-*.md (no adr-book.md)"); |
| 365 | mdContent = BuildAutoConcatBody(); |
| 366 | if (string.IsNullOrEmpty(mdContent)) return 1; |
| 367 | } |
| 368 | |
| 369 | mdContent = ExpandIncludes(mdContent, adrRoot); |
| 370 | |
| 371 | Directory.CreateDirectory(buildDir); |
| 372 | Directory.CreateDirectory(outHtmlDir); |
| 373 | Directory.CreateDirectory(outPdfDir); |
| 374 | Directory.CreateDirectory(outTxtDir); |
| 375 | |
| 376 | var expandedMdPath = Path.Combine(buildDir, $"{baseName}.md"); |
| 377 | File.WriteAllText(expandedMdPath, mdContent, utf8NoBom); |
| 378 | Console.WriteLine($"OK: {expandedMdPath}"); |
| 379 | |
| 380 | var cssPath = Path.Combine(adrRoot, "adr.css"); |
| 381 | if (File.Exists(cssPath)) |
| 382 | File.Copy(cssPath, Path.Combine(outHtmlDir, "adr.css"), true); |
| 383 | |
| 384 | var htmlPath = Path.Combine(outHtmlDir, $"{baseName}.html"); |
| 385 | var cssArg = File.Exists(cssPath) ? " -c \"adr.css\"" : ""; |
| 386 | var expandedFull = Path.GetFullPath(expandedMdPath); |
| 387 | var htmlArgs = $"\"{expandedFull}\" -o \"{baseName}.html\" --standalone --metadata lang=ru --toc --toc-depth=3{cssArg}"; |
| 388 | if (Run(pandocPath, htmlArgs, outHtmlDir) != 0) |
| 389 | { |
| 390 | Console.WriteLine("Pandoc → HTML failed."); |
| 391 | return 1; |
| 392 | } |
| 393 | Console.WriteLine($"OK: {htmlPath}"); |
| 394 | TryInjectMermaidScript(htmlPath); |
| 395 | |
| 396 | var txtPath = Path.Combine(outTxtDir, $"{baseName}.txt"); |
| 397 | var txtArgs = $"\"{expandedFull}\" -t plain -o \"{txtPath}\""; |
| 398 | if (Run(pandocPath, txtArgs) == 0) |
| 399 | Console.WriteLine($"OK: {txtPath}"); |
| 400 | else |
| 401 | Console.WriteLine("Warning: plain text export failed."); |
| 402 | |
| 403 | var pdfPath = Path.Combine(outPdfDir, $"{baseName}.pdf"); |
| 404 | var pdfPandocPath = Path.Combine(outPdfDir, $"{baseName}.pandoc.pdf"); |
| 405 | |
| 406 | var pdfDirect = $"\"{expandedFull}\" -o \"{pdfPandocPath}\" --metadata lang=ru --pdf-engine=xelatex -V mainfont=\"Times New Roman\" -V monofont=\"Consolas\""; |
| 407 | var okPdf = Run(pandocPath, pdfDirect) == 0; |
| 408 | |
| 409 | if (okPdf && File.Exists(pdfPandocPath)) |
| 410 | { |
| 411 | try |
| 412 | { |
| 413 | File.Copy(pdfPandocPath, pdfPath, true); |
| 414 | Console.WriteLine($"OK: {pdfPath} (Pandoc PDF engine)"); |
| 415 | } |
| 416 | catch (Exception ex) |
| 417 | { |
| 418 | Console.WriteLine($"Warning: Pandoc PDF: {ex.Message}"); |
| 419 | Console.WriteLine($"OK: {pdfPandocPath}"); |
| 420 | } |
| 421 | return 0; |
| 422 | } |
| 423 | |
| 424 | var edgePath = FindEdge(); |
| 425 | if (string.IsNullOrEmpty(edgePath)) |
| 426 | { |
| 427 | Console.WriteLine("PDF: нет LaTeX и не найден Edge — пропуск PDF. HTML и TXT в out/."); |
| 428 | return 0; |
| 429 | } |
| 430 | |
| 431 | var edgeArgs = $"--headless --disable-gpu --no-pdf-header-footer --print-to-pdf=\"{pdfPath}\" \"{htmlPath}\""; |
| 432 | if (Run(edgePath, edgeArgs) != 0) |
| 433 | { |
| 434 | Console.WriteLine("Edge PDF failed."); |
| 435 | return 1; |
| 436 | } |
| 437 | System.Threading.Thread.Sleep(1500); |
| 438 | if (File.Exists(pdfPath)) |
| 439 | Console.WriteLine($"OK: {pdfPath} (Edge print)"); |
| 440 | else |
| 441 | Console.WriteLine("PDF file not created after Edge print."); |
| 442 | |
| 443 | return 0; |
| 444 | |