diff --git a/Frontmatter.Test/DelimiterLineTests.cs b/Frontmatter.Test/DelimiterLineTests.cs index 34eacb2..198892b 100644 --- a/Frontmatter.Test/DelimiterLineTests.cs +++ b/Frontmatter.Test/DelimiterLineTests.cs @@ -191,4 +191,75 @@ public void HasFrontmatter_DelimiterWithoutLineEnding_IsNotFrontmatter() Assert.IsFalse(Frontmatter.HasFrontmatter("\uFEFF")); Assert.IsFalse(Frontmatter.HasFrontmatter(string.Empty)); } + + private static readonly string PandocDocument = + $"---{Nl}title: A{Nl}...{Nl}# Heading{Nl}{Nl}Important paragraph.{Nl}{Nl}---{Nl}{Nl}More text{Nl}"; + + [TestMethod] + public void ExtractFrontmatter_BlockClosedByDocumentEndMarker_ReadsTheBlock() + { + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(PandocDocument); + + Assert.IsNotNull(frontmatter); + Assert.HasCount(1, frontmatter); + Assert.AreEqual("A", frontmatter["title"]); + } + + [TestMethod] + public void ExtractBody_BlockClosedByDocumentEndMarker_KeepsTheBodyBeforeALaterRule() + { + string body = Frontmatter.ExtractBody(PandocDocument); + + Assert.AreEqual($"# Heading{Nl}{Nl}Important paragraph.{Nl}{Nl}---{Nl}{Nl}More text", body); + } + + [TestMethod] + public void RemoveFrontmatter_BlockClosedByDocumentEndMarker_KeepsTheBodyBeforeALaterRule() + { + string result = Frontmatter.RemoveFrontmatter(PandocDocument); + + Assert.AreEqual($"# Heading{Nl}{Nl}Important paragraph.{Nl}{Nl}---{Nl}{Nl}More text{Nl}", result); + } + + [TestMethod] + public void ReplaceFrontmatter_BlockClosedByDocumentEndMarker_ReplacesOnlyTheBlock() + { + string result = Frontmatter.ReplaceFrontmatter(PandocDocument, new() { { "title", "B" } }); + + Assert.AreEqual($"---{Nl}title: B{Nl}---{Nl}# Heading{Nl}{Nl}Important paragraph.{Nl}{Nl}---{Nl}{Nl}More text{Nl}", result); + } + + [TestMethod] + public void ExtractFrontmatter_BlockClosedByDocumentEndMarkerWithNoLaterRule_ReadsTheBlock() + { + string input = $"---{Nl}title: A{Nl}... {Nl}Body{Nl}"; + + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(input); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual("A", frontmatter["title"]); + Assert.AreEqual("Body", Frontmatter.ExtractBody(input)); + } + + [TestMethod] + public void ExtractFrontmatter_DocumentEndMarkerOnTheFirstLine_IsNotAnOpener() + { + string input = $"...{Nl}title: A{Nl}---{Nl}Body{Nl}"; + + Assert.IsFalse(Frontmatter.HasFrontmatter(input)); + Assert.IsNull(Frontmatter.ExtractFrontmatter(input)); + Assert.AreEqual(input, Frontmatter.RemoveFrontmatter(input)); + } + + [TestMethod] + public void ExtractFrontmatter_IndentedDocumentEndMarkerInsideAValue_DoesNotCloseTheBlock() + { + string input = $"---{Nl}notes: |{Nl} ...{Nl}title: A{Nl}---{Nl}Body{Nl}"; + + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(input); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual("A", frontmatter["title"]); + Assert.AreEqual("Body", Frontmatter.ExtractBody(input)); + } } diff --git a/Frontmatter/Frontmatter.cs b/Frontmatter/Frontmatter.cs index e804d85..76b1f83 100644 --- a/Frontmatter/Frontmatter.cs +++ b/Frontmatter/Frontmatter.cs @@ -15,6 +15,7 @@ public static class Frontmatter /// The delimiter that marks the beginning and end of a frontmatter section. /// private const string FrontmatterDelimiter = "---"; + private const string DocumentEndMarker = "..."; /// /// The byte order mark a document may begin with when it was decoded without stripping it. @@ -368,7 +369,9 @@ private static Dictionary CombineAllFrontmatterObjects(List /// 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 + /// at the next delimiter line, including one at the very end of the document. YAML's document end + /// marker ..., which Pandoc metadata blocks use, also closes a block but never opens one; a + /// rewritten document always closes its frontmatter with ---. Further blocks are /// 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. @@ -390,12 +393,13 @@ private static bool TrySplitFrontmatterBlocks(string input, out List blo List<(int Start, int End)> lines = SplitLines(input); lines[0] = (OpeningLineStart(input), lines[0].End); bool IsDelimiterAt(int index) => IsDelimiterLine(input[lines[index].Start..lines[index].End]); + bool IsCloserAt(int index) => IsClosingDelimiterLine(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)) + while (close < lines.Count && !IsCloserAt(close)) { close++; } @@ -521,6 +525,15 @@ private static string TrimBody(string body) /// True if the line is a frontmatter delimiter, false otherwise. private static bool IsDelimiterLine(string line) => line.TrimEnd() == FrontmatterDelimiter; + /// + /// Checks whether a line can close a frontmatter block: a delimiter, or YAML's document end marker + /// ..., either allowing trailing whitespace. + /// + /// The line to check, without its line ending. + /// True if the line closes a frontmatter block, false otherwise. + private static bool IsClosingDelimiterLine(string line) => + IsDelimiterLine(line) || line.TrimEnd() == DocumentEndMarker; + /// /// Combines two frontmatter dictionaries into a single dictionary. ///