diff --git a/Frontmatter.Test/DelimiterLineTests.cs b/Frontmatter.Test/DelimiterLineTests.cs
new file mode 100644
index 0000000..4ae1ec7
--- /dev/null
+++ b/Frontmatter.Test/DelimiterLineTests.cs
@@ -0,0 +1,109 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Frontmatter.Test;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests that frontmatter delimiters are recognised only as whole lines at the top of a document.
+///
+[TestClass]
+public class DelimiterLineTests
+{
+ private static readonly string Nl = Environment.NewLine;
+
+ [TestMethod]
+ public void ExtractFrontmatter_BodyHasHorizontalRulesAroundKeyValueLine_LeavesItInTheBody()
+ {
+ string input = $"---{Nl}title: A{Nl}---{Nl}Intro{Nl}{Nl}---{Nl}{Nl}note: hi{Nl}{Nl}---{Nl}{Nl}More{Nl}";
+
+ Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(input);
+ string body = Frontmatter.ExtractBody(input);
+
+ Assert.IsNotNull(frontmatter);
+ Assert.HasCount(1, frontmatter);
+ Assert.AreEqual("A", frontmatter["title"]);
+ Assert.AreEqual($"Intro{Nl}{Nl}---{Nl}{Nl}note: hi{Nl}{Nl}---{Nl}{Nl}More", body);
+ }
+
+ [TestMethod]
+ public void CombineFrontmatter_BodyHasHorizontalRulesAroundKeyValueLine_DoesNotMergeItIntoTheHeader()
+ {
+ string input = $"---{Nl}title: A{Nl}---{Nl}Intro{Nl}{Nl}---{Nl}{Nl}note: hi{Nl}{Nl}---{Nl}{Nl}More{Nl}";
+
+ string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None);
+
+ Assert.AreEqual($"---{Nl}title: A{Nl}---{Nl}Intro{Nl}{Nl}---{Nl}{Nl}note: hi{Nl}{Nl}---{Nl}{Nl}More{Nl}", result);
+ }
+
+ [TestMethod]
+ public void ExtractFrontmatter_ValueContainsDelimiterMidLine_KeepsTheWholeHeader()
+ {
+ string input = $"---{Nl}text: foo---{Nl}title: A{Nl}---{Nl}Body{Nl}";
+
+ Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(input);
+ string body = Frontmatter.ExtractBody(input);
+
+ Assert.IsNotNull(frontmatter);
+ Assert.HasCount(2, frontmatter);
+ Assert.AreEqual("foo---", frontmatter["text"]);
+ Assert.AreEqual("A", frontmatter["title"]);
+ Assert.AreEqual("Body", body);
+ }
+
+ [TestMethod]
+ public void CombineFrontmatter_TwoConsecutiveBlocks_CombinesThemWithoutLeavingTheSecondInTheBody()
+ {
+ string input = $"---{Nl}title: A{Nl}---{Nl}---{Nl}author: B{Nl}---{Nl}Body{Nl}";
+
+ string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None);
+
+ Assert.AreEqual($"---{Nl}title: A{Nl}author: B{Nl}---{Nl}Body{Nl}", result);
+ }
+
+ [TestMethod]
+ public void ExtractFrontmatter_ClosingDelimiterEndsTheDocument_ReadsTheFrontmatter()
+ {
+ string input = $"---{Nl}title: A{Nl}---";
+
+ Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(input);
+
+ Assert.IsNotNull(frontmatter);
+ Assert.HasCount(1, frontmatter);
+ Assert.AreEqual("A", frontmatter["title"]);
+ }
+
+ [TestMethod]
+ public void AddFrontmatter_ClosingDelimiterEndsTheDocument_KeepsTheExistingProperties()
+ {
+ string input = $"---{Nl}title: A{Nl}---";
+
+ string result = Frontmatter.AddFrontmatter(input, new Dictionary { ["author"] = "B" });
+ Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(result);
+
+ Assert.IsNotNull(frontmatter);
+ Assert.HasCount(2, frontmatter);
+ Assert.AreEqual("A", frontmatter["title"]);
+ Assert.AreEqual("B", frontmatter["author"]);
+ }
+
+ [TestMethod]
+ public void AddFrontmatter_ExistingFrontmatterIsUnreadable_ReturnsTheDocumentUnchanged()
+ {
+ string input = $"---{Nl}title: [unclosed{Nl}---{Nl}Body{Nl}";
+
+ string result = Frontmatter.AddFrontmatter(input, new Dictionary { ["author"] = "B" });
+
+ Assert.AreEqual(input, result);
+ }
+
+ [TestMethod]
+ public void AddFrontmatter_ExistingFrontmatterIsEmpty_AddsTheProperties()
+ {
+ string input = $"---{Nl}---{Nl}Body{Nl}";
+
+ string result = Frontmatter.AddFrontmatter(input, new Dictionary { ["author"] = "B" });
+
+ Assert.AreEqual($"---{Nl}author: B{Nl}---{Nl}Body{Nl}", result);
+ }
+}
diff --git a/Frontmatter/Frontmatter.cs b/Frontmatter/Frontmatter.cs
index c147943..85bc8ed 100644
--- a/Frontmatter/Frontmatter.cs
+++ b/Frontmatter/Frontmatter.cs
@@ -171,6 +171,15 @@ public static string AddFrontmatter(string input, Dictionary fro
{
// Document already has frontmatter, use CombineFrontmatter instead
Dictionary? existing = ExtractFrontmatter(input);
+
+ // Frontmatter that is present but could not be read is left alone rather than overwritten, so a
+ // gap in parsing loses nothing. Only a genuinely empty block is treated as having no properties.
+ if (existing == null
+ && (!TrySplitFrontmatterBlocks(input, out List blocks, out _) || blocks.Exists(block => !string.IsNullOrWhiteSpace(block))))
+ {
+ return input;
+ }
+
Dictionary combined = CombineFrontmatterObjects(existing ?? [], frontmatter);
return ReplaceFrontmatter(input, combined);
}
@@ -309,20 +318,15 @@ private static string DetectNewLine(string input)
private static List> ExtractFrontmatterObjects(string input, out string body)
{
List> frontmatterObjects = [];
- body = input;
- if (!HasFrontmatter(input))
+ if (!TrySplitFrontmatterBlocks(input, out List blocks, out body))
{
return frontmatterObjects;
}
- string documentNewLine = DetectNewLine(input);
- string[] sections = input.Split([FrontmatterDelimiter + documentNewLine], StringSplitOptions.None);
- body = string.Join(FrontmatterDelimiter + documentNewLine, sections.Skip(2));
-
- for (int i = 1; i < sections.Length; i += 2)
+ foreach (string block in blocks)
{
- string section = sections[i].Trim();
+ string section = block.Trim();
if (string.IsNullOrWhiteSpace(section))
{
continue;
@@ -337,6 +341,100 @@ private static List> ExtractFrontmatterObjects(string
return frontmatterObjects;
}
+ ///
+ /// Splits the frontmatter blocks at the top of a document from its body.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The markdown document content as a string.
+ /// The raw text of each frontmatter block, in document order.
+ /// The document after the last frontmatter block, or the whole input when there is none.
+ /// True if at least one closed frontmatter block was found, false otherwise.
+ private static bool TrySplitFrontmatterBlocks(string input, out List blocks, out string body)
+ {
+ blocks = [];
+ body = input;
+
+ if (!HasFrontmatter(input))
+ {
+ return false;
+ }
+
+ List<(int Start, int End)> lines = SplitLines(input);
+ bool IsDelimiterAt(int index) => IsDelimiterLine(input[lines[index].Start..lines[index].End]);
+
+ int next = 0;
+ while (next < lines.Count && IsDelimiterAt(next))
+ {
+ int close = next + 1;
+ while (close < lines.Count && !IsDelimiterAt(close))
+ {
+ close++;
+ }
+
+ if (close == lines.Count)
+ {
+ break;
+ }
+
+ blocks.Add(close == next + 1
+ ? string.Empty
+ : input[lines[next + 1].Start..lines[close - 1].End]);
+ next = close + 1;
+ }
+
+ if (blocks.Count == 0)
+ {
+ return false;
+ }
+
+ body = next < lines.Count ? input[lines[next].Start..] : string.Empty;
+ return true;
+ }
+
+ ///
+ /// Finds the lines of a document, recognising CRLF, LF and CR endings wherever they occur.
+ ///
+ /// The document to split.
+ /// The start and end offset of each line, excluding its line ending.
+ private static List<(int Start, int End)> SplitLines(string input)
+ {
+ List<(int Start, int End)> lines = [];
+ int start = 0;
+
+ for (int i = 0; i < input.Length; i++)
+ {
+ char c = input[i];
+ if (c is not '\r' and not '\n')
+ {
+ continue;
+ }
+
+ lines.Add((start, i));
+ if (c == '\r' && i + 1 < input.Length && input[i + 1] == '\n')
+ {
+ i++;
+ }
+
+ start = i + 1;
+ }
+
+ lines.Add((start, input.Length));
+ return lines;
+ }
+
+ ///
+ /// Checks whether a line is a frontmatter delimiter, allowing trailing whitespace.
+ ///
+ /// The line to check, without its line ending.
+ /// True if the line is a frontmatter delimiter, false otherwise.
+ private static bool IsDelimiterLine(string line) => line.TrimEnd() == FrontmatterDelimiter;
+
///
/// Combines two frontmatter dictionaries into a single dictionary.
///