diff --git a/Frontmatter.Test/DelimiterLineTests.cs b/Frontmatter.Test/DelimiterLineTests.cs index f418972..34eacb2 100644 --- a/Frontmatter.Test/DelimiterLineTests.cs +++ b/Frontmatter.Test/DelimiterLineTests.cs @@ -107,6 +107,43 @@ public void AddFrontmatter_ExistingFrontmatterIsEmpty_AddsTheProperties() Assert.AreEqual($"---{Nl}author: B{Nl}---{Nl}Body{Nl}", result); } + [TestMethod] + public void ExtractBody_RuleUnderHeaderAroundNonYamlText_KeepsTheWholeBody() + { + string input = $"---{Nl}title: A{Nl}---{Nl}---{Nl}Important paragraph{Nl}{Nl}---{Nl}More{Nl}"; + + Assert.AreEqual($"---{Nl}Important paragraph{Nl}{Nl}---{Nl}More", Frontmatter.ExtractBody(input)); + Assert.AreEqual($"---{Nl}Important paragraph{Nl}{Nl}---{Nl}More{Nl}", Frontmatter.RemoveFrontmatter(input)); + } + + [TestMethod] + public void AddFrontmatter_RuleUnderHeaderAroundNonYamlText_KeepsTheWholeBody() + { + string input = $"---{Nl}title: A{Nl}---{Nl}---{Nl}Important paragraph{Nl}{Nl}---{Nl}More{Nl}"; + + string result = Frontmatter.AddFrontmatter(input, new Dictionary { ["author"] = "B" }); + + Assert.AreEqual($"---{Nl}title: A{Nl}author: B{Nl}---{Nl}---{Nl}Important paragraph{Nl}{Nl}---{Nl}More{Nl}", result); + } + + [TestMethod] + public void CombineFrontmatter_RuleUnderHeaderAroundYamlLikeText_DoesNotMergeItIntoTheHeader() + { + string input = $"---{Nl}title: My Post{Nl}---{Nl}---{Nl}Note: read this first{Nl}{Nl}---{Nl}{Nl}# Heading{Nl}"; + + string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None); + + Assert.AreEqual(input, result); + } + + [TestMethod] + public void ExtractBody_EmptyBlockUnderHeader_KeepsTheRulesInTheBody() + { + string input = $"---{Nl}title: A{Nl}---{Nl}---{Nl}---{Nl}Body{Nl}"; + + Assert.AreEqual($"---{Nl}---{Nl}Body", Frontmatter.ExtractBody(input)); + } + [TestMethod] [DataRow("--- ", DisplayName = "Trailing space")] [DataRow("---\t", DisplayName = "Trailing tab")] diff --git a/Frontmatter/Frontmatter.cs b/Frontmatter/Frontmatter.cs index 18a4a8f..fcdb2cd 100644 --- a/Frontmatter/Frontmatter.cs +++ b/Frontmatter/Frontmatter.cs @@ -337,8 +337,9 @@ private static List> ExtractFrontmatterObjects(string /// Delimiters are recognised only as whole lines, so a --- inside a value or a markdown /// horizontal rule in the body is never mistaken for one. The first line opens a block, which closes /// at the next delimiter line, including one at the very end of the document. Further blocks are - /// consumed only while each opens on the line straight after the previous one closed; the body is - /// everything after the last block consumed. + /// consumed only while each opens on the line straight after the previous one closed and holds + /// frontmatter rather than body text (see ); the body is everything after + /// the last block consumed. /// /// The markdown document content as a string. /// The raw text of each frontmatter block, in document order. @@ -372,9 +373,19 @@ private static bool TrySplitFrontmatterBlocks(string input, out List blo break; } - blocks.Add(close == next + 1 + string block = close == next + 1 ? string.Empty - : input[lines[next + 1].Start..lines[close - 1].End]); + : input[lines[next + 1].Start..lines[close - 1].End]; + + // A thematic break placed directly under the header looks like the start of another block. + // Only the first block is taken on trust; a later one must look like frontmatter, or it and + // everything after it stay in the body. + if (blocks.Count > 0 && !IsFollowOnBlock(block, lines, next, close, input)) + { + break; + } + + blocks.Add(block); next = close + 1; } @@ -387,6 +398,33 @@ private static bool TrySplitFrontmatterBlocks(string input, out List blo return true; } + /// + /// Checks whether a block that follows the first one is really frontmatter rather than body text + /// set between two thematic breaks. + /// + /// + /// A follow-on block has to hold a non-empty YAML mapping, and it may not open or close with a blank + /// line. Stacked headers are written tight against their delimiters, whereas markdown around a + /// thematic break is usually spaced out from it, so a blank line marks the block as body text even + /// when that text happens to parse as YAML. + /// + /// The raw text between the block's delimiters. + /// The lines of the document. + /// The index of the block's opening delimiter line. + /// The index of the block's closing delimiter line. + /// The document. + /// True if the block should be read as frontmatter, false if it belongs to the body. + private static bool IsFollowOnBlock(string block, List<(int Start, int End)> lines, int open, int close, string input) + { + bool IsBlankAt(int index) => string.IsNullOrWhiteSpace(input[lines[index].Start..lines[index].End]); + + return close > open + 1 + && !IsBlankAt(open + 1) + && !IsBlankAt(close - 1) + && YamlSerializer.TryParseYamlObject(block.Trim(), out Dictionary? properties) + && properties.Count > 0; + } + /// /// Finds the lines of a document, recognising CRLF, LF and CR endings wherever they occur. ///