diff --git a/TriasDev.Templify.Tests/Odt/OdtValidationTests.cs b/TriasDev.Templify.Tests/Odt/OdtValidationTests.cs
index 858eb11..11c18ff 100644
--- a/TriasDev.Templify.Tests/Odt/OdtValidationTests.cs
+++ b/TriasDev.Templify.Tests/Odt/OdtValidationTests.cs
@@ -140,7 +140,46 @@ public void TableRowLoop_UnmatchedInsideCell_IsStillReported()
ValidationResult result = Validate(template);
- Assert.Contains(result.Errors, e => e.Message.Contains("'{{#if A}}'", StringComparison.Ordinal));
+ // Reported once, with the row-level message that processing fails with (as for Word templates).
+ ValidationError error = Assert.Single(result.Errors);
+ Assert.Equal(ValidationErrorType.UnmatchedConditionalStart, error.Type);
+ Assert.Equal("Table row conditional start marker '{{#if A}}' has no matching '{{/if}}'.", error.Message);
+ }
+
+ [Fact]
+ public void UnmatchedListItemMarkers_AreReportedOnce_WithTheMessageProcessingFailsWith()
+ {
+ OdtDocumentBuilder template = new OdtDocumentBuilder()
+ .AddXml("{{#if A}}b")
+ .AddXml("{{#foreach Items}}b");
+ byte[] bytes = template.ToBytes();
+
+ ValidationResult result = new OdtTemplateProcessor().ValidateTemplate(new MemoryStream(bytes));
+ ProcessingResult processing = new OdtTemplateProcessor().ProcessTemplate(
+ bytes,
+ new Dictionary { ["A"] = true, ["Items"] = new List { 1 } },
+ out _);
+
+ Assert.Equal(
+ new[]
+ {
+ "UnmatchedConditionalStart: List item conditional start marker '{{#if A}}' has no matching '{{/if}}'.",
+ "UnmatchedLoopStart: List item loop start marker '{{#foreach Items}}' has no matching '{{/foreach}}'.",
+ },
+ result.Errors.Select(e => $"{e.Type}: {e.Message}").Order(StringComparer.Ordinal));
+ Assert.False(processing.IsSuccess);
+ Assert.Contains(processing.ErrorMessage!, result.Errors.Select(e => "Processing failed: " + e.Message));
+ }
+
+ [Fact]
+ public void SameUnmatchedMarker_InTwoParagraphs_IsReportedOnce()
+ {
+ ValidationResult result = Validate(new OdtDocumentBuilder()
+ .AddTable(new[] { "{{#if A}} x" }, new[] { "y" })
+ .AddParagraph("{{#if A}}"));
+
+ ValidationError error = Assert.Single(result.Errors);
+ Assert.Equal("Conditional start marker '{{#if A}}' has no matching '{{/if}}'.", error.Message);
}
[Fact]
diff --git a/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs b/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs
index eeeb5d0..4e2fb80 100644
--- a/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs
+++ b/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs
@@ -270,6 +270,9 @@ private sealed class SyntaxWalker
{
private readonly HashSet _allPlaceholders;
private readonly List _errors;
+ /// The marker quoted in an unmatched-marker message, e.g. '{{#if A}}'.
+ private static readonly Regex _quotedMarker = new Regex(@"start marker '(\{\{#[^']*\}\})'", RegexOptions.CultureInvariant);
+
private readonly HashSet _reported = new HashSet(StringComparer.Ordinal);
public SyntaxWalker(HashSet allPlaceholders, List errors)
@@ -339,7 +342,12 @@ private IReadOnlyList Detect(Func> detect)
}
catch (TemplateSyntaxException ex)
{
- if (_reported.Add(ex.Message))
+ // A marker that fails at row or list-item level is found again inside its cell or item, with the
+ // cell-level wording ("Conditional start marker '{{#if A}}' ..."); report each marker once, with
+ // the message of the outermost level, which is the one processing fails with.
+ Match marker = _quotedMarker.Match(ex.Message);
+ string key = marker.Success ? $"{ex.ErrorType}:{marker.Groups[1].Value}" : ex.Message;
+ if (_reported.Add(key))
{
_errors.Add(ValidationError.Create(ex.ErrorType, ex.Message));
}