Skip to content

Commit f3badde

Browse files
author
Netdocs
committed
fix(snippets): preserve indentation of block includes
Snippet block includes discarded the marker's leading indentation, inserting file content at column 0. When the include sat inside an indented context - a fenced code block within an admonition, or a nested list item - the un-indented content broke out of that context: the code fence closed empty and the included file was parsed as top-level markdown (script comments became h1 headers, breaking the whole page). Capture the marker's leading whitespace and prepend it to every non-blank line of the included (and recursively expanded) content, matching pymdownx.snippets. Add regression tests: indented include keeps indentation, blank lines are not indented, and unindented includes are unchanged.
1 parent 157414f commit f3badde

2 files changed

Lines changed: 79 additions & 3 deletions

File tree

src/Netdocs.Plugins/SnippetsPlugin.cs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ private string Expand(string markdown, int depth)
8383
return IncludeRegex().Replace(markdown, match =>
8484
{
8585
var spec = match.Groups["spec"].Value.Trim();
86+
var indent = match.Groups["indent"].Value;
8687
string? section = null;
8788
var path = spec;
8889
var colon = spec.LastIndexOf(':');
@@ -94,15 +95,41 @@ private string Expand(string markdown, int depth)
9495

9596
var resolved = Resolve(path);
9697
if (resolved is null || !File.Exists(resolved))
97-
return $"<!-- snippet not found: {path} -->";
98+
return $"{indent}<!-- snippet not found: {path} -->";
9899

99100
var content = File.ReadAllText(resolved);
100101
if (section is not null)
101102
content = ExtractSection(content, section);
102-
return Expand(content, depth + 1);
103+
var expanded = Expand(content, depth + 1);
104+
return Reindent(expanded, indent);
103105
});
104106
}
105107

108+
/// <summary>Prepends the snippet marker's leading indentation to every line of the
109+
/// included content. pymdownx.snippets does this so that an include placed inside an
110+
/// indented context (a code fence within an admonition, a nested list item, etc.)
111+
/// keeps that context instead of breaking out of it. Without this, an indented
112+
/// <c>--8&lt;--</c> inside a fenced code block yields an empty fence and dumps the file
113+
/// as top-level markdown.</summary>
114+
private static string Reindent(string content, string indent)
115+
{
116+
if (indent.Length == 0) return content;
117+
118+
var normalized = content.Replace("\r\n", "\n");
119+
var lines = normalized.Split('\n');
120+
var sb = new StringBuilder();
121+
for (var i = 0; i < lines.Length; i++)
122+
{
123+
// Don't indent blank lines (avoids trailing whitespace-only lines).
124+
if (lines[i].Length > 0)
125+
sb.Append(indent);
126+
sb.Append(lines[i]);
127+
if (i < lines.Length - 1)
128+
sb.Append('\n');
129+
}
130+
return sb.ToString();
131+
}
132+
106133
/// <summary>Parses trailing <c>key="value"</c> pairs from a parameterized include.</summary>
107134
private static IEnumerable<(string Key, string Value)> ParseArgs(string args)
108135
{
@@ -156,7 +183,7 @@ private static string TrimTrailingMarkerLine(string slice)
156183
_ => []
157184
};
158185

159-
[GeneratedRegex(@"^[ \t]*(?:;\s*)?--8<--(?:-)?[ \t]+""(?<spec>[^""]+)""[ \t]*\r?$", RegexOptions.Multiline)]
186+
[GeneratedRegex(@"^(?<indent>[ \t]*)(?:;\s*)?--8<--(?:-)?[ \t]+""(?<spec>[^""]+)""[ \t]*\r?$", RegexOptions.Multiline)]
160187
private static partial Regex IncludeRegex();
161188

162189
// Inline parameterized include: `--8<-- "file" key="value" ...` (at least one argument).

tests/Netdocs.Core.Tests/SnippetsPluginTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,55 @@ public async Task ResolvesFromDefaultRootSnippetsDirectory()
146146
Assert.Contains("Default snippets dir works.", result);
147147
}
148148

149+
[Fact]
150+
public async Task PreservesIndentation_ForIndentedInclude()
151+
{
152+
// A `--8<--` indented inside a code fence within an admonition must keep that
153+
// indentation on every included line. Otherwise the fence closes empty and the
154+
// file spills out as top-level markdown (script comments become <h1> headers).
155+
Write("script.sh", "#!/usr/bin/env bash\n# a comment\nset -euo pipefail\necho hi");
156+
var plugin = PluginWith(("base_path", new object?[] { _dir }));
157+
158+
var markdown = " --8<-- \"script.sh\"";
159+
var result = await plugin.ProcessAsync(Page, markdown, Site(), default);
160+
161+
// Every non-blank included line is prefixed with the marker's 4-space indent.
162+
Assert.Contains(" #!/usr/bin/env bash", result);
163+
Assert.Contains(" # a comment", result);
164+
Assert.Contains(" set -euo pipefail", result);
165+
Assert.Contains(" echo hi", result);
166+
Assert.DoesNotContain("--8<--", result);
167+
}
168+
169+
[Fact]
170+
public async Task PreservesIndentation_BlankLinesNotIndented()
171+
{
172+
// Blank lines in the included file must not gain trailing whitespace.
173+
Write("script.sh", "line one\n\nline two");
174+
var plugin = PluginWith(("base_path", new object?[] { _dir }));
175+
176+
var result = await plugin.ProcessAsync(Page, " --8<-- \"script.sh\"", Site(), default);
177+
178+
Assert.Contains(" line one", result);
179+
Assert.Contains(" line two", result);
180+
// The blank line stays blank (no " \n" with trailing spaces).
181+
Assert.DoesNotContain(" \n", result.Replace("\r\n", "\n"));
182+
}
183+
184+
[Fact]
185+
public async Task NoIndentation_UnindentedIncludeUnchanged()
186+
{
187+
// An include at column 0 must not gain any indentation (regression guard so the
188+
// reindent logic is a no-op for the common case).
189+
Write("notice.md", "line a\nline b");
190+
var plugin = PluginWith(("base_path", new object?[] { _dir }));
191+
192+
var result = await plugin.ProcessAsync(Page, "--8<-- \"notice.md\"", Site(), default);
193+
194+
Assert.Contains("line a\nline b", result.Replace("\r\n", "\n"));
195+
Assert.DoesNotContain(" line a", result);
196+
}
197+
149198
private sealed class FakeContext(IReadOnlyDictionary<string, object?> options) : IPluginContext
150199
{
151200
public SiteConfig Config { get; init; } = new();

0 commit comments

Comments
 (0)