Forge

docs/ / adr/build-adr.csx · branch develop

#!/usr/bin/env dotnet script
// Сборка всех нумерованных ADR в один документ (как resume: фрагменты через INCLUDE).
// Запуск из каталога docs/adr:
//   dotnet script build-adr.csx
//   dotnet script build-adr.csx --root "D:\path\to\docs\adr"
// Нужно: dotnet tool install -g dotnet-script, Pandoc; PDF без LaTeX — Microsoft Edge.
//
// Директивы в .md (тот же контракт, что resume/build-resume.csx):
//   {{ INCLUDE: путь/от/корня/adr.md }}
//   {{ INCLUDE_MANIFEST: build/order.txt }}
//   {{ INCLUDE_GLOB: snippets/*.md }}
//
// Режимы:
//   • Если есть adr-book.md — он корень (YAML + INCLUDE*), как resume.md.
//   • Иначе — склеиваются все файлы ^\d{4}-.+\.md$ в docs/adr по имени, между ними ---; затем ExpandIncludes.
// Опция: --book adr-book-ui.md — другой корневой файл и префикс артефактов (adr-book-ui.html и т.д.).

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

var adrRoot = ResolveAdrRoot();

string bookArg = null;
var argvForBook = Environment.GetCommandLineArgs();
for (var bi = 0; bi < argvForBook.Length - 1; bi++)
    if (string.Equals(argvForBook[bi], "--book", StringComparison.OrdinalIgnoreCase))
    {
        bookArg = argvForBook[bi + 1];
        break;
    }

var bookRel = string.IsNullOrWhiteSpace(bookArg) ? "adr-book.md" : bookArg.Trim();
var baseName = Path.GetFileNameWithoutExtension(bookRel);
if (string.IsNullOrEmpty(baseName)) baseName = "adr-book";
var bookPath = Path.Combine(adrRoot, bookRel.Replace('/', Path.DirectorySeparatorChar));

var buildDir = Path.Combine(adrRoot, "build");
var outDir = Path.Combine(adrRoot, "out");
var outHtmlDir = Path.Combine(outDir, "html");
var outPdfDir = Path.Combine(outDir, "pdf");
var outTxtDir = Path.Combine(outDir, "txt");
var utf8NoBom = new UTF8Encoding(false);

string ResolveAdrRoot()
{
    var argv = Environment.GetCommandLineArgs();
    for (var i = 0; i < argv.Length - 1; i++)
        if (string.Equals(argv[i], "--root", StringComparison.OrdinalIgnoreCase))
            return Path.GetFullPath(argv[i + 1]);
    return Directory.GetCurrentDirectory();
}

string ExpandManifest(string manifestRel, string baseDir)
{
    var manifestPath = Path.Combine(baseDir, manifestRel.Replace('/', Path.DirectorySeparatorChar));
    if (!File.Exists(manifestPath))
    {
        Console.WriteLine($"Warning: INCLUDE_MANIFEST not found: {manifestPath}");
        return "";
    }
    var sb = new StringBuilder();
    foreach (var line in File.ReadAllLines(manifestPath, Encoding.UTF8))
    {
        var t = line.Trim();
        if (t.Length == 0 || t.StartsWith("#", StringComparison.Ordinal)) continue;
        var p = Path.Combine(baseDir, t.Replace('/', Path.DirectorySeparatorChar));
        if (!File.Exists(p))
        {
            Console.WriteLine($"Warning: manifest line file missing: {p}");
            continue;
        }
        if (sb.Length > 0) sb.AppendLine();
        sb.Append(File.ReadAllText(p, Encoding.UTF8));
    }
    return sb.ToString();
}

string ExpandGlob(string globRel, string baseDir)
{
    var g = globRel.Replace('/', Path.DirectorySeparatorChar).Trim();
    var star = g.IndexOf('*');
    if (star < 0)
    {
        Console.WriteLine("Warning: INCLUDE_GLOB needs a * pattern (e.g. snippets/*.md)");
        return "";
    }
    var beforeStar = g.Substring(0, star);
    var lastSep = Math.Max(beforeStar.LastIndexOf(Path.DirectorySeparatorChar), beforeStar.LastIndexOf('/'));
    var relDir = lastSep >= 0 ? beforeStar.Substring(0, lastSep) : "";
    var pattern = g.Substring(star);
    var dir = Path.Combine(baseDir, relDir);
    if (!Directory.Exists(dir))
    {
        Console.WriteLine($"Warning: INCLUDE_GLOB directory not found: {dir}");
        return "";
    }
    var files = Directory.GetFiles(dir, pattern, SearchOption.TopDirectoryOnly)
        .Where(f => !Path.GetFileName(f).StartsWith("_", StringComparison.Ordinal))
        .OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
        .ToArray();
    var sb = new StringBuilder();
    foreach (var f in files)
    {
        if (sb.Length > 0) sb.AppendLine();
        sb.Append(File.ReadAllText(f, Encoding.UTF8));
    }
    return sb.ToString();
}

// Fenced ``` … ``` → токены, чтобы примеры {{ INCLUDE: … }} в кодовых блоках не раскрывались.
(string Masked, Dictionary<string, string> FenceMap) MaskFencedBlocks(string content)
{
    var map = new Dictionary<string, string>();
    var sb = new StringBuilder();
    var pos = 0;
    var n = 0;
    while (pos < content.Length)
    {
        var start = content.IndexOf("```", pos, StringComparison.Ordinal);
        if (start < 0)
        {
            sb.Append(content, pos, content.Length - pos);
            break;
        }
        sb.Append(content, pos, start - pos);
        var lineEnd = content.IndexOf('\n', start);
        if (lineEnd < 0)
        {
            sb.Append(content, start, content.Length - start);
            break;
        }
        var contentStart = lineEnd + 1;
        var closeLineStart = -1;
        var scan = contentStart;
        while (scan < content.Length)
        {
            var nl = content.IndexOf('\n', scan);
            if (nl < 0)
                break;
            var lineBegin = nl + 1;
            if (lineBegin + 3 <= content.Length && content[lineBegin] == '`' && content[lineBegin + 1] == '`' && content[lineBegin + 2] == '`')
            {
                var afterTicks = lineBegin + 3;
                if (afterTicks >= content.Length || content[afterTicks] == '\r' || content[afterTicks] == '\n')
                {
                    closeLineStart = lineBegin;
                    break;
                }
            }
            scan = nl + 1;
        }
        if (closeLineStart < 0)
        {
            sb.Append(content, start, content.Length - start);
            break;
        }
        var blockEnd = closeLineStart + 3;
        while (blockEnd < content.Length && (content[blockEnd] == '\r' || content[blockEnd] == '\n'))
            blockEnd++;
        var full = content.Substring(start, blockEnd - start);
        var token = $"\uE000FENCE{n++}\uE000";
        map[token] = full;
        sb.Append(token);
        pos = blockEnd;
    }
    return (sb.ToString(), map);
}

string UnmaskFencedBlocks(string content, Dictionary<string, string> map)
{
    foreach (var kv in map)
        content = content.Replace(kv.Key, kv.Value, StringComparison.Ordinal);
    return content;
}

string ExpandIncludes(string content, string baseDir)
{
    var (masked, fenceMap) = MaskFencedBlocks(content);
    content = masked;

    var rxManifest = new Regex(@"\{\{\s*INCLUDE_MANIFEST:\s*([^\s\}]+)\s*\}\}");
    var rxGlob = new Regex(@"\{\{\s*INCLUDE_GLOB:\s*([^\s\}]+)\s*\}\}");
    var rxSingle = new Regex(@"\{\{\s*INCLUDE:\s*([^\s\}]+)\s*\}\}");

    while (true)
    {
        var mMan = rxManifest.Match(content);
        var mGlob = rxGlob.Match(content);
        var mOne = rxSingle.Match(content);

        var candidates = new List<(int Index, int Kind, Match M)>();
        if (mMan.Success) candidates.Add((mMan.Index, 0, mMan));
        if (mGlob.Success) candidates.Add((mGlob.Index, 1, mGlob));
        if (mOne.Success) candidates.Add((mOne.Index, 2, mOne));
        if (candidates.Count == 0) break;

        var pick = candidates.OrderBy(c => c.Index).First();
        var m = pick.M;
        string replacement;
        switch (pick.Kind)
        {
            case 0:
                replacement = ExpandManifest(m.Groups[1].Value.Trim(), baseDir);
                break;
            case 1:
                replacement = ExpandGlob(m.Groups[1].Value.Trim(), baseDir);
                break;
            default:
                var includePath = Path.Combine(baseDir, m.Groups[1].Value.Trim().Replace('/', Path.DirectorySeparatorChar));
                if (!File.Exists(includePath))
                {
                    Console.WriteLine($"Warning: INCLUDE not found: {includePath}");
                    replacement = "";
                }
                else
                    replacement = File.ReadAllText(includePath, Encoding.UTF8);
                break;
        }

        content = content.Substring(0, m.Index) + replacement + content.Substring(m.Index + m.Length);
    }

    return UnmaskFencedBlocks(content, fenceMap);
}

int Run(string executable, string args, string workingDirectory = null)
{
    try
    {
        var psi = new ProcessStartInfo
        {
            FileName = executable,
            Arguments = args,
            UseShellExecute = false,
            CreateNoWindow = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true
        };
        if (!string.IsNullOrEmpty(workingDirectory))
            psi.WorkingDirectory = workingDirectory;
        using var p = Process.Start(psi);
        var stdout = p!.StandardOutput.ReadToEnd();
        var stderr = p.StandardError.ReadToEnd();
        p.WaitForExit();

        if (p.ExitCode != 0)
        {
            Console.WriteLine($"Error: command failed (exit={p.ExitCode}): {executable} {args}");
            if (!string.IsNullOrWhiteSpace(stdout))
                Console.WriteLine(stdout.TrimEnd());
            if (!string.IsNullOrWhiteSpace(stderr))
                Console.WriteLine(stderr.TrimEnd());
        }

        return p.ExitCode;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error: {ex.Message}");
        return -1;
    }
}

void TryInjectMermaidScript(string htmlPath)
{
    try
    {
        if (string.IsNullOrEmpty(htmlPath) || !File.Exists(htmlPath)) return;
        var html = File.ReadAllText(htmlPath, Encoding.UTF8);
        if (!html.Contains("class=\"mermaid\"", StringComparison.Ordinal)) return;
        if (html.Contains("mermaid.min.js", StringComparison.OrdinalIgnoreCase)) return;
        if (!html.Contains("</body>", StringComparison.OrdinalIgnoreCase)) return;
        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";
        html = html.Replace("</body>", snippet + "</body>", StringComparison.OrdinalIgnoreCase);
        File.WriteAllText(htmlPath, html, new UTF8Encoding(false));
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Warning: Mermaid inject skipped: {ex.Message}");
    }
}

string FindEdge()
{
    var x86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
    var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
    foreach (var p in new[]
             {
                 Path.Combine(x86 ?? "", "Microsoft", "Edge", "Application", "msedge.exe"),
                 Path.Combine(pf, "Microsoft", "Edge", "Application", "msedge.exe")
             })
        if (File.Exists(p)) return p;
    return null;
}

string FindInPath(string name)
{
    if (string.IsNullOrEmpty(Path.GetExtension(name)) && Environment.OSVersion.Platform == PlatformID.Win32NT)
        name += ".exe";
    var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
    foreach (var dir in pathEnv.Split(Path.PathSeparator))
    {
        var full = Path.Combine(dir.Trim(), name);
        if (File.Exists(full)) return full;
    }
    return null;
}

string BuildAutoConcatBody()
{
    var rx = new Regex(@"^\d{4}-.+\.md$", RegexOptions.IgnoreCase);
    var files = Directory.GetFiles(adrRoot, "*.md", SearchOption.TopDirectoryOnly)
        .Where(f => rx.IsMatch(Path.GetFileName(f)))
        .OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
        .ToArray();
    if (files.Length == 0)
    {
        Console.WriteLine("No numbered ADR files (NNNN-*.md) in " + adrRoot);
        return "";
    }
    Console.WriteLine($"Auto-concat: {files.Length} ADR file(s).");
    var sb = new StringBuilder();
    sb.AppendLine("---");
    sb.AppendLine($"title: \"Cascade IDE — Architecture Decision Records\"");
    sb.AppendLine($"date: {DateTime.UtcNow:yyyy-MM-dd}");
    sb.AppendLine("lang: ru");
    sb.AppendLine("---");
    sb.AppendLine();
    sb.AppendLine("*Сборка: все файлы `NNNN-*.md` в каталоге `docs/adr`, порядок по имени. DOCX не генерируется по политике сборки.*");
    sb.AppendLine();
    for (var i = 0; i < files.Length; i++)
    {
        if (i > 0) sb.AppendLine().AppendLine("---").AppendLine();
        sb.Append(File.ReadAllText(files[i], Encoding.UTF8));
    }
    return sb.ToString();
}

var pandocPath = FindInPath("pandoc");
if (string.IsNullOrEmpty(pandocPath))
{
    Console.WriteLine("Pandoc not found. Install: winget install JohnMacFarlane.Pandoc");
    return 1;
}

string mdContent;
if (!string.IsNullOrWhiteSpace(bookArg) && !File.Exists(bookPath))
{
    Console.WriteLine($"--book file not found: {bookPath}");
    return 1;
}
if (File.Exists(bookPath))
{
    Console.WriteLine($"Source: {bookRel}");
    mdContent = File.ReadAllText(bookPath, Encoding.UTF8);
}
else
{
    Console.WriteLine("Source: auto-concat of NNNN-*.md (no adr-book.md)");
    mdContent = BuildAutoConcatBody();
    if (string.IsNullOrEmpty(mdContent)) return 1;
}

mdContent = ExpandIncludes(mdContent, adrRoot);

Directory.CreateDirectory(buildDir);
Directory.CreateDirectory(outHtmlDir);
Directory.CreateDirectory(outPdfDir);
Directory.CreateDirectory(outTxtDir);

var expandedMdPath = Path.Combine(buildDir, $"{baseName}.md");
File.WriteAllText(expandedMdPath, mdContent, utf8NoBom);
Console.WriteLine($"OK: {expandedMdPath}");

var cssPath = Path.Combine(adrRoot, "adr.css");
if (File.Exists(cssPath))
    File.Copy(cssPath, Path.Combine(outHtmlDir, "adr.css"), true);

var htmlPath = Path.Combine(outHtmlDir, $"{baseName}.html");
var cssArg = File.Exists(cssPath) ? " -c \"adr.css\"" : "";
var expandedFull = Path.GetFullPath(expandedMdPath);
var htmlArgs = $"\"{expandedFull}\" -o \"{baseName}.html\" --standalone --metadata lang=ru --toc --toc-depth=3{cssArg}";
if (Run(pandocPath, htmlArgs, outHtmlDir) != 0)
{
    Console.WriteLine("Pandoc → HTML failed.");
    return 1;
}
Console.WriteLine($"OK: {htmlPath}");
TryInjectMermaidScript(htmlPath);

var txtPath = Path.Combine(outTxtDir, $"{baseName}.txt");
var txtArgs = $"\"{expandedFull}\" -t plain -o \"{txtPath}\"";
if (Run(pandocPath, txtArgs) == 0)
    Console.WriteLine($"OK: {txtPath}");
else
    Console.WriteLine("Warning: plain text export failed.");

var pdfPath = Path.Combine(outPdfDir, $"{baseName}.pdf");
var pdfPandocPath = Path.Combine(outPdfDir, $"{baseName}.pandoc.pdf");

var pdfDirect = $"\"{expandedFull}\" -o \"{pdfPandocPath}\" --metadata lang=ru --pdf-engine=xelatex -V mainfont=\"Times New Roman\" -V monofont=\"Consolas\"";
var okPdf = Run(pandocPath, pdfDirect) == 0;

if (okPdf && File.Exists(pdfPandocPath))
{
    try
    {
        File.Copy(pdfPandocPath, pdfPath, true);
        Console.WriteLine($"OK: {pdfPath} (Pandoc PDF engine)");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Warning: Pandoc PDF: {ex.Message}");
        Console.WriteLine($"OK: {pdfPandocPath}");
    }
    return 0;
}

var edgePath = FindEdge();
if (string.IsNullOrEmpty(edgePath))
{
    Console.WriteLine("PDF: нет LaTeX и не найден Edge — пропуск PDF. HTML и TXT в out/.");
    return 0;
}

var edgeArgs = $"--headless --disable-gpu --no-pdf-header-footer --print-to-pdf=\"{pdfPath}\" \"{htmlPath}\"";
if (Run(edgePath, edgeArgs) != 0)
{
    Console.WriteLine("Edge PDF failed.");
    return 1;
}
System.Threading.Thread.Sleep(1500);
if (File.Exists(pdfPath))
    Console.WriteLine($"OK: {pdfPath} (Edge print)");
else
    Console.WriteLine("PDF file not created after Edge print.");

return 0;
View only · write via MCP/CIDE