From 06a4c78335ea904e2c3a1c36cf915fa27cecee09 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 04:25:30 +0000 Subject: [PATCH] Count only a closed block as frontmatter, so AddFrontmatter writes a header above an opening rule [patch] HasFrontmatter returned true as soon as the first line was a delimiter, while the block splitter needs a closing one. A document opening with a thematic break that never closes made AddFrontmatter take the "present but unreadable" branch and return the input unchanged, silently dropping the new properties. HasFrontmatter now asks the splitter, and the first-line check moves to a private OpensWithDelimiter helper that keeps the BOM and trailing-whitespace rules. Fixes ktsu-dev/Frontmatter#157 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAEodt8KsqJyMYzdfoAfo --- Frontmatter.Test/UnclosedOpeningRuleTests.cs | 49 ++++++++++++++++++++ Frontmatter/Frontmatter.cs | 24 ++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 Frontmatter.Test/UnclosedOpeningRuleTests.cs diff --git a/Frontmatter.Test/UnclosedOpeningRuleTests.cs b/Frontmatter.Test/UnclosedOpeningRuleTests.cs new file mode 100644 index 0000000..3d41546 --- /dev/null +++ b/Frontmatter.Test/UnclosedOpeningRuleTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter.Test; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that a document opening with a --- rule that never closes is treated as having no frontmatter. +/// +[TestClass] +public class UnclosedOpeningRuleTests +{ + private static readonly string Nl = Environment.NewLine; + + private static readonly string Document = $"---{Nl}{Nl}# Heading{Nl}{Nl}Text{Nl}"; + + [TestMethod] + public void HasFrontmatter_OpeningRuleNeverCloses_IsFalse() + { + Assert.IsFalse(Frontmatter.HasFrontmatter($"---{Nl}{Nl}# Heading{Nl}")); + Assert.IsFalse(Frontmatter.HasFrontmatter(Document)); + } + + [TestMethod] + public void ExtractFrontmatter_OpeningRuleNeverCloses_IsNull() + { + Assert.IsNull(Frontmatter.ExtractFrontmatter(Document)); + } + + [TestMethod] + public void AddFrontmatter_OpeningRuleNeverCloses_WritesAHeaderInFrontOfTheRule() + { + string result = Frontmatter.AddFrontmatter(Document, new Dictionary { ["title"] = "T" }); + + Assert.AreEqual($"---{Nl}title: T{Nl}---{Nl}---{Nl}{Nl}# Heading{Nl}{Nl}Text{Nl}", result); + + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(result); + Assert.IsNotNull(frontmatter); + Assert.HasCount(1, frontmatter); + Assert.AreEqual("T", frontmatter["title"]); + Assert.AreEqual($"---{Nl}{Nl}# Heading{Nl}{Nl}Text", Frontmatter.ExtractBody(result)); + } + + [TestMethod] + public void RemoveFrontmatter_OpeningRuleNeverCloses_LeavesTheDocumentAlone() + { + Assert.AreEqual(Document, Frontmatter.RemoveFrontmatter(Document)); + } +} diff --git a/Frontmatter/Frontmatter.cs b/Frontmatter/Frontmatter.cs index 76b1f83..f0b715f 100644 --- a/Frontmatter/Frontmatter.cs +++ b/Frontmatter/Frontmatter.cs @@ -144,12 +144,28 @@ public static string CombineFrontmatter(string input, FrontmatterNaming property /// Checks if a markdown document contains frontmatter. /// /// The markdown document content as a string. + /// + /// Only a closed block counts. A document that opens with a --- line that never closes, such as + /// a markdown thematic break, has no frontmatter, so writes a header in front + /// of it instead of leaving it alone. + /// /// True if the document contains frontmatter, false otherwise. /// Thrown when input is null. public static bool HasFrontmatter(string input) { Ensure.NotNull(input); + return TrySplitFrontmatterBlocks(input, out _, out _); + } + + /// + /// Checks whether the first line of a document is a frontmatter delimiter, whether or not a closing + /// delimiter follows it. + /// + /// The document to inspect. + /// True if the document's first line is a delimiter, false otherwise. + private static bool OpensWithDelimiter(string input) + { // The opening delimiter follows the same rule as the closing one, so trailing whitespace an editor // left behind does not hide the header. A leading byte order mark is skipped, since callers that // decode bytes or streams themselves keep it where File.ReadAllText would strip it. @@ -311,8 +327,9 @@ private static List> ExtractFrontmatterObjects(string /// The markdown document content as a string. /// Output parameter that will contain the markdown body without frontmatter. /// - /// True when the document opens with a frontmatter delimiter but a non-blank block failed to parse, or no - /// closed block could be found. Such text is missing from the returned objects but not from the document. + /// True when a non-blank frontmatter block failed to parse. Such text is missing from the returned objects + /// but not from the document. An opening delimiter that never closes is not frontmatter, so it is not + /// reported here. /// /// A collection of dictionaries representing each frontmatter section that parsed. private static List> ExtractFrontmatterObjects(string input, out string body, out bool hasUnreadableBlock) @@ -322,7 +339,6 @@ private static List> ExtractFrontmatterObjects(string if (!TrySplitFrontmatterBlocks(input, out List blocks, out body)) { - hasUnreadableBlock = HasFrontmatter(input); return frontmatterObjects; } @@ -385,7 +401,7 @@ private static bool TrySplitFrontmatterBlocks(string input, out List blo blocks = []; body = input; - if (!HasFrontmatter(input)) + if (!OpensWithDelimiter(input)) { return false; }