Forge
csharp4405de34
1using System.Text;
2using System.Text.RegularExpressions;
3
4namespace CascadeIDE.Services;
5
6/// <summary>
7/// Expand non-standard include directives in Markdown/diagram sources for publishing.
8/// Canonical syntax: <c>{{ INCLUDE: relative/path/to/file }}</c> (case-insensitive, spaces allowed).
9/// </summary>
10public static class MarkdownIncludeExpansion
11{
12 private static readonly Regex IncludeLineRegex = new(
13 @"^\s*\{\{\s*include\s*:\s*(?<path>[^}]+?)\s*\}\}\s*$",
14 RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
15
16 public sealed record Options(int MaxDepth = 5);
17
18 public static string ExpandMarkdown(string markdown, string markdownFilePath, Options? options = null)
19 {
20 if (markdown is null)
21 return "";
22 if (string.IsNullOrWhiteSpace(markdownFilePath))
23 throw new ArgumentException("markdownFilePath is required", nameof(markdownFilePath));
24
25 var opts = options ?? new Options();
26 var errors = new List<string>();
27 var fullMd = CanonicalFilePath.Normalize(markdownFilePath);
28 var baseDir = Path.GetDirectoryName(fullMd) ?? Directory.GetCurrentDirectory();
29 var stack = new Stack<string>();
30
31 var result = ExpandMarkdownCore(markdown, baseDir, fullMd, opts, stack, depth: 0, errors);
32 if (errors.Count > 0)
33 throw new IncludeExpansionException(errors);
34 return result;
35 }
36
37 private static string ExpandMarkdownCore(
38 string markdown,
39 string baseDir,
40 string markdownPathForErrors,
41 Options opts,
42 Stack<string> stack,
43 int depth,
44 List<string> errors)
45 {
46 // Expand includes only inside fenced code blocks to avoid surprising replacements in prose.
47 var sb = new StringBuilder(markdown.Length + 128);
48 using var reader = new StringReader(markdown);
49 string? line;
50 bool inFence = false;
51 string? fenceMarker = null;
52
53 while ((line = reader.ReadLine()) is not null)
54 {
55 var trimmed = line.TrimStart();
56 if (IsFenceStartOrEnd(trimmed, out var marker))
57 {
58 if (!inFence)
59 {
60 inFence = true;
61 fenceMarker = marker;
62 }
63 else if (fenceMarker is not null && string.Equals(marker, fenceMarker, StringComparison.Ordinal))
64 {
65 inFence = false;
66 fenceMarker = null;
67 }
68
69 sb.AppendLine(line);
70 continue;
71 }
72
73 if (inFence)
74 {
75 var m = IncludeLineRegex.Match(line);
76 if (m.Success)
77 {
78 var rel = (m.Groups["path"].Value ?? "").Trim();
79 var expanded = TryExpandIncludeFile(rel, baseDir, markdownPathForErrors, opts, stack, depth, errors);
80 if (expanded is not null)
81 {
82 sb.Append(expanded);
83 if (!expanded.EndsWith('\n'))
84 sb.AppendLine();
85 continue;
86 }
87 }
88 }
89
90 sb.AppendLine(line);
91 }
92
93 return sb.ToString();
94 }
95
96 private static bool IsFenceStartOrEnd(string trimmedLine, out string marker)
97 {
98 if (trimmedLine.StartsWith("```", StringComparison.Ordinal))
99 {
100 marker = "```";
101 return true;
102 }
103 if (trimmedLine.StartsWith("~~~", StringComparison.Ordinal))
104 {
105 marker = "~~~";
106 return true;
107 }
108 marker = "";
109 return false;
110 }
111
112 private static string? TryExpandIncludeFile(
113 string relativePath,
114 string baseDir,
115 string markdownPathForErrors,
116 Options opts,
117 Stack<string> stack,
118 int depth,
119 List<string> errors)
120 {
121 if (string.IsNullOrWhiteSpace(relativePath))
122 {
123 errors.Add($"INCLUDE: empty path in '{markdownPathForErrors}'.");
124 return null;
125 }
126
127 if (depth >= opts.MaxDepth)
128 {
129 errors.Add($"INCLUDE: max depth {opts.MaxDepth} exceeded while expanding '{relativePath}' in '{markdownPathForErrors}'.");
130 return null;
131 }
132
133 var full = CanonicalFilePath.Normalize(Path.Combine(baseDir, relativePath));
134 if (stack.Contains(full, StringComparer.OrdinalIgnoreCase))
135 {
136 errors.Add($"INCLUDE: cycle detected: '{full}'.");
137 return null;
138 }
139
140 if (!File.Exists(full))
141 {
142 errors.Add($"INCLUDE: file not found: '{full}' (from '{markdownPathForErrors}').");
143 return null;
144 }
145
146 try
147 {
148 stack.Push(full);
149 var text = File.ReadAllText(full);
150
151 // Included files are typically diagram sources; allow nested includes line-by-line without Markdown fences.
152 var expanded = ExpandDiagramTextCore(
153 text,
154 Path.GetDirectoryName(full) ?? baseDir,
155 full,
156 opts,
157 stack,
158 depth + 1,
159 errors);
160
161 return expanded;
162 }
163 catch (Exception ex)
164 {
165 errors.Add($"INCLUDE: failed to read '{full}': {ex.Message}");
166 return null;
167 }
168 finally
169 {
170 if (stack.Count > 0 && string.Equals(stack.Peek(), full, StringComparison.OrdinalIgnoreCase))
171 stack.Pop();
172 }
173 }
174
175 private static string ExpandDiagramTextCore(
176 string text,
177 string baseDir,
178 string sourcePathForErrors,
179 Options opts,
180 Stack<string> stack,
181 int depth,
182 List<string> errors)
183 {
184 var sb = new StringBuilder(text.Length + 64);
185 using var reader = new StringReader(text);
186 string? line;
187 while ((line = reader.ReadLine()) is not null)
188 {
189 var m = IncludeLineRegex.Match(line);
190 if (m.Success)
191 {
192 var rel = (m.Groups["path"].Value ?? "").Trim();
193 var expanded = TryExpandIncludeFile(rel, baseDir, sourcePathForErrors, opts, stack, depth, errors);
194 if (expanded is not null)
195 {
196 sb.Append(expanded);
197 if (!expanded.EndsWith('\n'))
198 sb.AppendLine();
199 continue;
200 }
201 }
202
203 sb.AppendLine(line);
204 }
205 return sb.ToString();
206 }
207}
208
209public sealed class IncludeExpansionException : Exception
210{
211 public IncludeExpansionException(IReadOnlyList<string> errors)
212 : base(BuildMessage(errors))
213 {
214 Errors = errors ?? Array.Empty<string>();
215 }
216
217 public IReadOnlyList<string> Errors { get; }
218
219 private static string BuildMessage(IReadOnlyList<string> errors)
220 {
221 if (errors is null || errors.Count == 0)
222 return "Include expansion failed.";
223 return "Include expansion failed:\n- " + string.Join("\n- ", errors);
224 }
225}
226
227
View only · write via MCP/CIDE