Skip to content

Commit 5433a11

Browse files
feat(templates): validate and warn on duplicate block definitions (#24)
2 parents bcd08c7 + aa6cc90 commit 5433a11

3 files changed

Lines changed: 150 additions & 2 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System.Text.RegularExpressions;
2+
using Microsoft.Extensions.Logging;
3+
4+
namespace Netdocs.Core.Templating;
5+
6+
/// <summary>
7+
/// Validates template files for duplicate block definitions, which silently override
8+
/// and can cause subtle bugs. Emits warnings for each duplicate found.
9+
/// </summary>
10+
public sealed class TemplateBlockValidator
11+
{
12+
private readonly ILogger _logger;
13+
14+
public TemplateBlockValidator(ILogger logger)
15+
{
16+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
17+
}
18+
19+
/// <summary>Validates a template file for duplicate block definitions.</summary>
20+
/// <param name="templatePath">Absolute path to the template file.</param>
21+
/// <param name="templateContent">Content of the template.</param>
22+
public void Validate(string templatePath, string templateContent)
23+
{
24+
var blocks = ExtractBlockNames(templateContent);
25+
var duplicates = blocks
26+
.GroupBy(b => b, StringComparer.OrdinalIgnoreCase)
27+
.Where(g => g.Count() > 1)
28+
.Select(g => g.Key)
29+
.ToList();
30+
31+
if (duplicates.Count == 0)
32+
return;
33+
34+
var relPath = Path.GetFileName(templatePath);
35+
foreach (var blockName in duplicates)
36+
{
37+
var count = blocks.Count(b => string.Equals(b, blockName, StringComparison.OrdinalIgnoreCase));
38+
_logger.LogWarning("Template file '{TemplatePath}' has {Count} definitions of block '{BlockName}' " +
39+
"(only the last one will be used). Consolidate them into a single block.",
40+
relPath, count, blockName);
41+
}
42+
}
43+
44+
/// <summary>
45+
/// Extracts all block names from a Scriban template using regex.
46+
/// Matches `{% block name %}...{% endblock %}` patterns (case-insensitive).
47+
/// </summary>
48+
private static List<string> ExtractBlockNames(string templateContent)
49+
{
50+
var result = new List<string>();
51+
// Match {% block <name> %} — capture the block name
52+
var pattern = @"{%\s*block\s+(\w+)\s*%}";
53+
var matches = Regex.Matches(templateContent, pattern, RegexOptions.IgnoreCase);
54+
55+
foreach (Match match in matches)
56+
{
57+
if (match.Groups[1].Value is { Length: > 0 } blockName)
58+
result.Add(blockName);
59+
}
60+
61+
return result;
62+
}
63+
}

src/Netdocs.Core/Templating/TemplateEngine.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using Scriban;
22
using Scriban.Runtime;
3+
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Logging.Abstractions;
35

46
namespace Netdocs.Core.Templating;
57

@@ -12,11 +14,13 @@ public sealed class TemplateEngine
1214
private readonly IReadOnlyList<string> _searchDirs;
1315
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, Template> _cache = new(StringComparer.OrdinalIgnoreCase);
1416
private readonly ThemeTemplateLoader _loader;
17+
private readonly TemplateBlockValidator _blockValidator;
1518

16-
public TemplateEngine(IEnumerable<string> searchDirsHighestPriorityFirst)
19+
public TemplateEngine(IEnumerable<string> searchDirsHighestPriorityFirst, ILogger? logger = null)
1720
{
1821
_searchDirs = searchDirsHighestPriorityFirst.Where(Directory.Exists).ToList();
1922
_loader = new ThemeTemplateLoader(_searchDirs);
23+
_blockValidator = new TemplateBlockValidator(logger ?? NullLogger.Instance);
2024
}
2125

2226
public bool TryResolve(string templateName, out string path) => _loader.TryResolvePath(templateName, out path);
@@ -43,7 +47,9 @@ private Template GetTemplate(string name)
4347
{
4448
if (!_loader.TryResolvePath(key, out var path))
4549
throw new FileNotFoundException($"Template '{key}' not found in: {string.Join(", ", _searchDirs)}");
46-
var template = Template.Parse(File.ReadAllText(path), path);
50+
var content = File.ReadAllText(path);
51+
_blockValidator.Validate(path, content);
52+
var template = Template.Parse(content, path);
4753
if (template.HasErrors)
4854
throw new InvalidOperationException($"Template '{key}' has errors:\n{string.Join('\n', template.Messages)}");
4955
return template;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using Xunit;
4+
5+
namespace Netdocs.Core.Tests;
6+
7+
public class TemplateBlockValidatorTests
8+
{
9+
private sealed class TestLogger : ILogger
10+
{
11+
public List<string> Warnings { get; } = new();
12+
13+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
14+
public bool IsEnabled(LogLevel logLevel) => true;
15+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
16+
{
17+
if (logLevel == LogLevel.Warning)
18+
Warnings.Add(formatter(state, exception));
19+
}
20+
}
21+
22+
[Fact]
23+
public void DetectsNoDuplicates_EmitsNoWarning()
24+
{
25+
var logger = new TestLogger();
26+
var validator = new Netdocs.Core.Templating.TemplateBlockValidator(logger);
27+
var content = "{% block header %}...{% endblock %}\n{% block content %}...{% endblock %}";
28+
29+
validator.Validate("test.html", content);
30+
31+
Assert.Empty(logger.Warnings);
32+
}
33+
34+
[Fact]
35+
public void DetectsDuplicateBlocks_EmitsWarning()
36+
{
37+
var logger = new TestLogger();
38+
var validator = new Netdocs.Core.Templating.TemplateBlockValidator(logger);
39+
var content = "{% block header %}first{% endblock %}\n{% block header %}second{% endblock %}";
40+
41+
validator.Validate("test.html", content);
42+
43+
Assert.Single(logger.Warnings);
44+
Assert.Contains("header", logger.Warnings[0]);
45+
Assert.Contains("2 definitions", logger.Warnings[0]);
46+
}
47+
48+
[Fact]
49+
public void DetectsMultipleDuplicates_EmitsWarningPerBlock()
50+
{
51+
var logger = new TestLogger();
52+
var validator = new Netdocs.Core.Templating.TemplateBlockValidator(logger);
53+
var content = @"
54+
{% block header %}a{% endblock %}
55+
{% block header %}b{% endblock %}
56+
{% block footer %}x{% endblock %}
57+
{% block footer %}y{% endblock %}
58+
{% block footer %}z{% endblock %}
59+
";
60+
61+
validator.Validate("test.html", content);
62+
63+
Assert.Equal(2, logger.Warnings.Count);
64+
Assert.Contains("header", logger.Warnings[0]);
65+
Assert.Contains("footer", logger.Warnings[1]);
66+
}
67+
68+
[Fact]
69+
public void CaseInsensitive_TreatsHeaderAndHEADERAsIdentical()
70+
{
71+
var logger = new TestLogger();
72+
var validator = new Netdocs.Core.Templating.TemplateBlockValidator(logger);
73+
var content = "{% block header %}{% endblock %}\n{% block HEADER %}{% endblock %}";
74+
75+
validator.Validate("test.html", content);
76+
77+
Assert.Single(logger.Warnings);
78+
}
79+
}

0 commit comments

Comments
 (0)