Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Frontmatter.Test/DelimiterLineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object> { ["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")]
Expand Down
46 changes: 42 additions & 4 deletions Frontmatter/Frontmatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
return input;
}

Dictionary<string, object> combinedFrontmatterObject = frontmatterObjects.First();

Check warning on line 90 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Indexing at 0 should be used instead of the "Enumerable" extension method "First"

Check warning on line 90 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Indexing at 0 should be used instead of the "Enumerable" extension method "First"
foreach (Dictionary<string, object> frontmatterObject in frontmatterObjects.Skip(1))
{
combinedFrontmatterObject = CombineFrontmatterObjects(combinedFrontmatterObject, frontmatterObject);
Expand Down Expand Up @@ -138,7 +138,7 @@
}

List<Dictionary<string, object>> frontmatterObjects = ExtractFrontmatterObjects(input, out _);
return frontmatterObjects.Count > 0 ? frontmatterObjects.First() : null;

Check warning on line 141 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Indexing at 0 should be used instead of the "Enumerable" extension method "First"

Check warning on line 141 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Indexing at 0 should be used instead of the "Enumerable" extension method "First"
}

/// <summary>
Expand Down Expand Up @@ -337,8 +337,9 @@
/// Delimiters are recognised only as whole lines, so a <c>---</c> 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 <see cref="IsFollowOnBlock"/>); the body is everything after
/// the last block consumed.
/// </remarks>
/// <param name="input">The markdown document content as a string.</param>
/// <param name="blocks">The raw text of each frontmatter block, in document order.</param>
Expand Down Expand Up @@ -372,9 +373,19 @@
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;
}

Expand All @@ -387,6 +398,33 @@
return true;
}

/// <summary>
/// Checks whether a block that follows the first one is really frontmatter rather than body text
/// set between two thematic breaks.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="block">The raw text between the block's delimiters.</param>
/// <param name="lines">The lines of the document.</param>
/// <param name="open">The index of the block's opening delimiter line.</param>
/// <param name="close">The index of the block's closing delimiter line.</param>
/// <param name="input">The document.</param>
/// <returns>True if the block should be read as frontmatter, false if it belongs to the body.</returns>
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<string, object>? properties)
&& properties.Count > 0;
}

/// <summary>
/// Finds the lines of a document, recognising CRLF, LF and CR endings wherever they occur.
/// </summary>
Expand Down
Loading