diff --git a/Frontmatter.Test/BodyIndentationTests.cs b/Frontmatter.Test/BodyIndentationTests.cs
new file mode 100644
index 0000000..6c3967f
--- /dev/null
+++ b/Frontmatter.Test/BodyIndentationTests.cs
@@ -0,0 +1,63 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Frontmatter.Test;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests that rewriting a document keeps the indentation of the body's first content line, so a body that
+/// opens with an indented code block is not turned into a prose paragraph.
+///
+[TestClass]
+public class BodyIndentationTests
+{
+ private static readonly string Nl = Environment.NewLine;
+
+ private static readonly string IndentedBody = $" int x = 1;{Nl} int y = 2;{Nl}{Nl}Text";
+
+ private static string Document() => $"---{Nl}title: T{Nl}---{Nl}{IndentedBody}{Nl}";
+
+ [TestMethod]
+ public void ExtractBody_IndentedCodeBlockFirst_KeepsIndentation() =>
+ Assert.AreEqual(IndentedBody, Frontmatter.ExtractBody(Document()));
+
+ [TestMethod]
+ public void RemoveFrontmatter_IndentedCodeBlockFirst_KeepsIndentation() =>
+ Assert.AreEqual($"{IndentedBody}{Nl}", Frontmatter.RemoveFrontmatter(Document()));
+
+ [TestMethod]
+ public void ReplaceFrontmatter_IndentedCodeBlockFirst_KeepsIndentation()
+ {
+ string result = Frontmatter.ReplaceFrontmatter(Document(), new Dictionary { ["title"] = "U" });
+
+ Assert.AreEqual(IndentedBody, Frontmatter.ExtractBody(result));
+ }
+
+ [TestMethod]
+ public void AddFrontmatter_ExistingFrontmatterAndIndentedCodeBlockFirst_KeepsIndentation()
+ {
+ string result = Frontmatter.AddFrontmatter(Document(), new Dictionary { ["date"] = 2024 });
+
+ Assert.AreEqual(IndentedBody, Frontmatter.ExtractBody(result));
+ }
+
+ [TestMethod]
+ public void AddFrontmatter_NoFrontmatterAndIndentedCodeBlockFirst_KeepsIndentation()
+ {
+ string result = Frontmatter.AddFrontmatter($" code{Nl}{Nl}Text{Nl}", new Dictionary { ["title"] = "U" });
+
+ Assert.AreEqual($" code{Nl}{Nl}Text", Frontmatter.ExtractBody(result));
+ }
+
+ [TestMethod]
+ public void ExtractBody_TabIndentedFirstLineAfterBlankLines_DropsOnlyTheBlankLines()
+ {
+ string input = $"---{Nl}title: T{Nl}---{Nl}{Nl} {Nl}\tindented{Nl}{Nl}";
+
+ Assert.AreEqual("\tindented", Frontmatter.ExtractBody(input));
+ }
+
+ [TestMethod]
+ public void ExtractBody_WhitespaceOnlyBody_ReturnsEmpty() =>
+ Assert.AreEqual(string.Empty, Frontmatter.ExtractBody($"---{Nl}title: T{Nl}---{Nl} {Nl}\t{Nl}"));
+}
diff --git a/Frontmatter/Frontmatter.cs b/Frontmatter/Frontmatter.cs
index a9e5575..e804d85 100644
--- a/Frontmatter/Frontmatter.cs
+++ b/Frontmatter/Frontmatter.cs
@@ -198,7 +198,7 @@ public static string AddFrontmatter(string input, Dictionary fro
string yamlFrontmatter = YamlSerializer.SerializeYamlObject(frontmatter).Trim();
string nl = Environment.NewLine;
- return $"{FrontmatterDelimiter}{nl}{yamlFrontmatter}{nl}{FrontmatterDelimiter}{nl}{input.Trim()}{nl}";
+ return $"{FrontmatterDelimiter}{nl}{yamlFrontmatter}{nl}{FrontmatterDelimiter}{nl}{TrimBody(input)}{nl}";
}
///
@@ -220,7 +220,7 @@ public static string ReplaceFrontmatter(string input, Dictionary
ExtractFrontmatterObjects(input, out string body);
string yamlFrontmatter = YamlSerializer.SerializeYamlObject(frontmatter).Trim();
string nl = Environment.NewLine;
- return $"{FrontmatterDelimiter}{nl}{yamlFrontmatter}{nl}{FrontmatterDelimiter}{nl}{body.Trim()}{nl}";
+ return $"{FrontmatterDelimiter}{nl}{yamlFrontmatter}{nl}{FrontmatterDelimiter}{nl}{TrimBody(body)}{nl}";
}
///
@@ -239,7 +239,7 @@ public static string RemoveFrontmatter(string input)
}
ExtractFrontmatterObjects(input, out string body);
- return body.Trim() + Environment.NewLine;
+ return TrimBody(body) + Environment.NewLine;
}
///
@@ -253,7 +253,7 @@ public static string ExtractBody(string input)
Ensure.NotNull(input);
ExtractFrontmatterObjects(input, out string body);
- return body.Trim();
+ return TrimBody(body);
}
///
@@ -488,6 +488,32 @@ private static bool IsFollowOnBlock(string block, List<(int Start, int End)> lin
return lines;
}
+ ///
+ /// Trims a document body for output, dropping the blank lines before its first content line and the
+ /// whitespace after its last. Unlike , it keeps the first content line's
+ /// indentation, so a body that opens with an indented code block or nested list content survives.
+ ///
+ /// The body to trim.
+ /// The body without leading blank lines or trailing whitespace.
+ private static string TrimBody(string body)
+ {
+ int start = 0;
+ for (int i = 0; i < body.Length; i++)
+ {
+ char c = body[i];
+ if (c == '\n')
+ {
+ start = i + 1;
+ }
+ else if (!char.IsWhiteSpace(c))
+ {
+ break;
+ }
+ }
+
+ return body[start..].TrimEnd();
+ }
+
///
/// Checks whether a line is a frontmatter delimiter, allowing trailing whitespace.
///