From 5bff696ae42a510c61a0ad38cb3a1a8128a12792 Mon Sep 17 00:00:00 2001 From: matt-edmondson Date: Sun, 27 Sep 2026 07:26:40 +0000 Subject: [PATCH] [patch] Keep a thematic break directly under the header in the body The splitter consumed another frontmatter block whenever a delimiter line followed the one that closed the header, so a "---" rule placed straight under the header opened a block that ran to the next rule. Body text in between was either deleted (when it was not YAML) or moved into the header (when it was). Only the first block is now taken on trust. A follow-on block must hold a non-empty YAML mapping and must not open or close with a blank line; otherwise it and everything after it stay in the body. Tight stacked headers, which are supported on purpose, are unaffected. Fixes #140 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KMqfUfzWj2Gmdz1EqYdnLe --- Frontmatter.Test/DelimiterLineTests.cs | 37 +++++++++++++++++++++ Frontmatter/Frontmatter.cs | 46 +++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/Frontmatter.Test/DelimiterLineTests.cs b/Frontmatter.Test/DelimiterLineTests.cs index 4ae1ec7..214642c 100644 --- a/Frontmatter.Test/DelimiterLineTests.cs +++ b/Frontmatter.Test/DelimiterLineTests.cs @@ -106,4 +106,41 @@ 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)); + } } diff --git a/Frontmatter/Frontmatter.cs b/Frontmatter/Frontmatter.cs index 85bc8ed..aecd9e5 100644 --- a/Frontmatter/Frontmatter.cs +++ b/Frontmatter/Frontmatter.cs @@ -348,8 +348,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. @@ -382,9 +383,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; } @@ -397,6 +408,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. ///