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
72 changes: 72 additions & 0 deletions Frontmatter.Test/StackedBlockPreservationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Frontmatter.Test;

using System.Collections.Generic;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Regression tests for documents with more than one stacked frontmatter block. The rebuilt header used
/// to be built from a subset of the blocks while the body still started after all of them, so the
/// blocks left out were deleted from the document.
/// </summary>
[TestClass]
public class StackedBlockPreservationTests
{
[TestMethod]
public void AddFrontmatter_TwoStackedBlocks_KeepsPropertiesFromEveryBlock()
{
const string input = "---\ntitle: T\n---\n---\ntags: [x]\n---\nbody\n";

string result = Frontmatter.AddFrontmatter(input, new() { ["author"] = "Me" });

Dictionary<string, object>? frontmatter = Frontmatter.ExtractFrontmatter(result);
Assert.IsNotNull(frontmatter);
Assert.AreEqual("T", frontmatter["title"]);
Assert.AreEqual("Me", frontmatter["author"]);
Assert.IsTrue(frontmatter.ContainsKey("tags"), "The second block's tags should survive adding a property");
Assert.AreEqual("body", Frontmatter.ExtractBody(result));
}

[TestMethod]
public void AddFrontmatter_StackedBlocksRepeatAKey_FirstBlockWins()
{
const string input = "---\ntitle: First\n---\n---\ntitle: Second\n---\nbody\n";

string result = Frontmatter.AddFrontmatter(input, new() { ["author"] = "Me" });

Dictionary<string, object>? frontmatter = Frontmatter.ExtractFrontmatter(result);
Assert.IsNotNull(frontmatter);
Assert.AreEqual("First", frontmatter["title"]);
}

// The first block is always read as frontmatter, so an unparseable first block followed by one that
// parses is the case where the unreadable text sits in the header rather than the body.
private const string UnreadableFirstBlock = "---\nkey: [unclosed\n---\n---\ntitle: T\n---\nbody\n";

[TestMethod]
public void AddFrontmatter_UnreadableFirstBlock_ReturnsInputUnchanged() =>
Assert.AreEqual(UnreadableFirstBlock, Frontmatter.AddFrontmatter(UnreadableFirstBlock, new() { ["author"] = "Me" }));

[TestMethod]
[DataRow(FrontmatterMergeStrategy.None)]
[DataRow(FrontmatterMergeStrategy.Conservative)]
[DataRow(FrontmatterMergeStrategy.Maximum)]
public void CombineFrontmatter_UnreadableFirstBlock_ReturnsInputUnchanged(FrontmatterMergeStrategy strategy)
{
string result = Frontmatter.CombineFrontmatter(UnreadableFirstBlock, FrontmatterNaming.Standard, FrontmatterOrder.Sorted, strategy);

Assert.AreEqual(UnreadableFirstBlock, result);
}

[TestMethod]
public void CombineFrontmatter_UnreadableSecondBlock_StaysVerbatimInTheBody()
{
const string input = "---\ntitle: T\n---\n---\nkey: [unclosed\n---\nbody\n";

string result = Frontmatter.CombineFrontmatter(input);

Assert.Contains("key: [unclosed", result);
}
}
58 changes: 45 additions & 13 deletions Frontmatter/Frontmatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,18 @@
return cachedResult;
}

List<Dictionary<string, object>> frontmatterObjects = ExtractFrontmatterObjects(input, out string body);
List<Dictionary<string, object>> frontmatterObjects = ExtractFrontmatterObjects(input, out string body, out bool hasUnreadableBlock);

if (frontmatterObjects.Count == 0)
// A block that could not be parsed would be dropped from the rebuilt header while the body still
// starts after it, so the document is left alone rather than losing that block's text.
if (frontmatterObjects.Count == 0 || hasUnreadableBlock)
{
// Cache the original content since no processing was needed
ProcessedFrontmatterCache.TryAdd(cacheKey, input);
return input;
}

Dictionary<string, object> combinedFrontmatterObject = frontmatterObjects.First();
foreach (Dictionary<string, object> frontmatterObject in frontmatterObjects.Skip(1))
{
combinedFrontmatterObject = CombineFrontmatterObjects(combinedFrontmatterObject, frontmatterObject);
}
Dictionary<string, object> combinedFrontmatterObject = CombineAllFrontmatterObjects(frontmatterObjects);

// Apply property merging if enabled
if (mergeStrategy != FrontmatterMergeStrategy.None)
Expand Down Expand Up @@ -138,7 +136,7 @@
}

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

Check warning on line 139 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 139 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 @@ -184,18 +182,17 @@

if (HasFrontmatter(input))
{
// Document already has frontmatter, use CombineFrontmatter instead
Dictionary<string, object>? existing = ExtractFrontmatter(input);
// Every existing block is folded in, since ReplaceFrontmatter keeps only the body after all of them.
List<Dictionary<string, object>> existing = ExtractFrontmatterObjects(input, out _, out bool hasUnreadableBlock);

// 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<string> blocks, out _) || blocks.Exists(block => !string.IsNullOrWhiteSpace(block))))
if (hasUnreadableBlock)
{
return input;
}

Dictionary<string, object> combined = CombineFrontmatterObjects(existing ?? [], frontmatter);
Dictionary<string, object> combined = CombineFrontmatterObjects(CombineAllFrontmatterObjects(existing), frontmatter);
return ReplaceFrontmatter(input, combined);
}

Expand Down Expand Up @@ -286,7 +283,7 @@
}

// Then add any remaining properties that weren't in the standard order
foreach (KeyValuePair<string, object> property in frontmatter)

Check warning on line 286 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 286 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (!sortedFrontmatter.ContainsKey(property.Key))
{
Expand All @@ -304,12 +301,27 @@
/// <param name="body">Output parameter that will contain the markdown body without frontmatter.</param>
/// <returns>A collection of dictionaries representing each frontmatter section.</returns>
/// <exception cref="InvalidOperationException">Thrown when there are too many frontmatter sections in the document.</exception>
private static List<Dictionary<string, object>> ExtractFrontmatterObjects(string input, out string body)
private static List<Dictionary<string, object>> ExtractFrontmatterObjects(string input, out string body) =>
ExtractFrontmatterObjects(input, out body, out _);

/// <summary>
/// Extracts all frontmatter objects from a markdown document, reporting whether any of it could not be read.
/// </summary>
/// <param name="input">The markdown document content as a string.</param>
/// <param name="body">Output parameter that will contain the markdown body without frontmatter.</param>
/// <param name="hasUnreadableBlock">
/// 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.
/// </param>
/// <returns>A collection of dictionaries representing each frontmatter section that parsed.</returns>
private static List<Dictionary<string, object>> ExtractFrontmatterObjects(string input, out string body, out bool hasUnreadableBlock)
{
List<Dictionary<string, object>> frontmatterObjects = [];
hasUnreadableBlock = false;

if (!TrySplitFrontmatterBlocks(input, out List<string> blocks, out body))
{
hasUnreadableBlock = HasFrontmatter(input);
return frontmatterObjects;
}

Expand All @@ -321,15 +333,35 @@
continue;
}

if (YamlSerializer.TryParseYamlObject(section, out Dictionary<string, object>? frontmatterObject) && frontmatterObject != null)

Check warning on line 336 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Change this condition so that it does not always evaluate to 'True'.

Check warning on line 336 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Change this condition so that it does not always evaluate to 'True'.
{
frontmatterObjects.Add(frontmatterObject);
}
else
{
hasUnreadableBlock = true;
}
}

return frontmatterObjects;
}

/// <summary>
/// Folds frontmatter objects into one, in document order, so an earlier block wins a repeated key.
/// </summary>
/// <param name="frontmatterObjects">The frontmatter objects to fold.</param>
/// <returns>The combined frontmatter, empty when there are no objects.</returns>
private static Dictionary<string, object> CombineAllFrontmatterObjects(List<Dictionary<string, object>> frontmatterObjects)
{
Dictionary<string, object> combined = [];
foreach (Dictionary<string, object> frontmatterObject in frontmatterObjects)
{
combined = CombineFrontmatterObjects(combined, frontmatterObject);
}

return combined;
}

/// <summary>
/// Splits the frontmatter blocks at the top of a document from its body.
/// </summary>
Expand Down Expand Up @@ -446,7 +478,7 @@
lines.Add((start, i));
if (c == '\r' && i + 1 < input.Length && input[i + 1] == '\n')
{
i++;

Check warning on line 481 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 481 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
}

start = i + 1;
Expand Down Expand Up @@ -481,7 +513,7 @@
}

// Then, add properties from dictionary b that don't exist in a
foreach (KeyValuePair<string, object> kvp in b)

Check warning on line 516 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 516 in Frontmatter/Frontmatter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (!combinedFrontmatterObject.ContainsKey(kvp.Key))
{
Expand Down
Loading