Forge
deeb25a2
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
18using System;
19using System.Collections.Generic;
20using System.Diagnostics;
21using System.IO;
22using System.Linq;
23using System.Text;
24using System.Text.RegularExpressions;
25
26var adrRoot = ResolveAdrRoot();
27
28string bookArg = null;
29var argvForBook = Environment.GetCommandLineArgs();
30for (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
37var bookRel = string.IsNullOrWhiteSpace(bookArg) ? "adr-book.md" : bookArg.Trim();
38var baseName = Path.GetFileNameWithoutExtension(bookRel);
39if (string.IsNullOrEmpty(baseName)) baseName = "adr-book";
40var bookPath = Path.Combine(adrRoot, bookRel.Replace('/', Path.DirectorySeparatorChar));
41
42var buildDir = Path.Combine(adrRoot, "build");
43var outDir = Path.Combine(adrRoot, "out");
44var outHtmlDir = Path.Combine(outDir, "html");
45var outPdfDir = Path.Combine(outDir, "pdf");
46var outTxtDir = Path.Combine(outDir, "txt");
47var utf8NoBom = new UTF8Encoding(false);
48
49string 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
58string 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
83string 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
174string 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
181string 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
231int 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
269void 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
288string 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
301string 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
314string 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
344var pandocPath = FindInPath("pandoc");
345if (string.IsNullOrEmpty(pandocPath))
346{
347 Console.WriteLine("Pandoc not found. Install: winget install JohnMacFarlane.Pandoc");
348 return 1;
349}
350
351string mdContent;
352if (!string.IsNullOrWhiteSpace(bookArg) && !File.Exists(bookPath))
353{
354 Console.WriteLine($"--book file not found: {bookPath}");
355 return 1;
356}
357if (File.Exists(bookPath))
358{
359 Console.WriteLine($"Source: {bookRel}");
360 mdContent = File.ReadAllText(bookPath, Encoding.UTF8);
361}
362else
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
369mdContent = ExpandIncludes(mdContent, adrRoot);
370
371Directory.CreateDirectory(buildDir);
372Directory.CreateDirectory(outHtmlDir);
373Directory.CreateDirectory(outPdfDir);
374Directory.CreateDirectory(outTxtDir);
375
376var expandedMdPath = Path.Combine(buildDir, $"{baseName}.md");
377File.WriteAllText(expandedMdPath, mdContent, utf8NoBom);
378Console.WriteLine($"OK: {expandedMdPath}");
379
380var cssPath = Path.Combine(adrRoot, "adr.css");
381if (File.Exists(cssPath))
382 File.Copy(cssPath, Path.Combine(outHtmlDir, "adr.css"), true);
383
384var htmlPath = Path.Combine(outHtmlDir, $"{baseName}.html");
385var cssArg = File.Exists(cssPath) ? " -c \"adr.css\"" : "";
386var expandedFull = Path.GetFullPath(expandedMdPath);
387var htmlArgs = $"\"{expandedFull}\" -o \"{baseName}.html\" --standalone --metadata lang=ru --toc --toc-depth=3{cssArg}";
388if (Run(pandocPath, htmlArgs, outHtmlDir) != 0)
389{
390 Console.WriteLine("Pandoc → HTML failed.");
391 return 1;
392}
393Console.WriteLine($"OK: {htmlPath}");
394TryInjectMermaidScript(htmlPath);
395
396var txtPath = Path.Combine(outTxtDir, $"{baseName}.txt");
397var txtArgs = $"\"{expandedFull}\" -t plain -o \"{txtPath}\"";
398if (Run(pandocPath, txtArgs) == 0)
399 Console.WriteLine($"OK: {txtPath}");
400else
401 Console.WriteLine("Warning: plain text export failed.");
402
403var pdfPath = Path.Combine(outPdfDir, $"{baseName}.pdf");
404var pdfPandocPath = Path.Combine(outPdfDir, $"{baseName}.pandoc.pdf");
405
406var pdfDirect = $"\"{expandedFull}\" -o \"{pdfPandocPath}\" --metadata lang=ru --pdf-engine=xelatex -V mainfont=\"Times New Roman\" -V monofont=\"Consolas\"";
407var okPdf = Run(pandocPath, pdfDirect) == 0;
408
409if (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
424var edgePath = FindEdge();
425if (string.IsNullOrEmpty(edgePath))
426{
427 Console.WriteLine("PDF: нет LaTeX и не найден Edge — пропуск PDF. HTML и TXT в out/.");
428 return 0;
429}
430
431var edgeArgs = $"--headless --disable-gpu --no-pdf-header-footer --print-to-pdf=\"{pdfPath}\" \"{htmlPath}\"";
432if (Run(edgePath, edgeArgs) != 0)
433{
434 Console.WriteLine("Edge PDF failed.");
435 return 1;
436}
437System.Threading.Thread.Sleep(1500);
438if (File.Exists(pdfPath))
439 Console.WriteLine($"OK: {pdfPath} (Edge print)");
440else
441 Console.WriteLine("PDF file not created after Edge print.");
442
443return 0;
444
View only · write via MCP/CIDE