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. ///