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
63 changes: 63 additions & 0 deletions src/Netdocs.Core/Templating/TemplateBlockValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;

namespace Netdocs.Core.Templating;

/// <summary>
/// Validates template files for duplicate block definitions, which silently override
/// and can cause subtle bugs. Emits warnings for each duplicate found.
/// </summary>
public sealed class TemplateBlockValidator
{
private readonly ILogger _logger;

public TemplateBlockValidator(ILogger logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

/// <summary>Validates a template file for duplicate block definitions.</summary>
/// <param name="templatePath">Absolute path to the template file.</param>
/// <param name="templateContent">Content of the template.</param>
public void Validate(string templatePath, string templateContent)
{
var blocks = ExtractBlockNames(templateContent);
var duplicates = blocks
.GroupBy(b => b, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();

if (duplicates.Count == 0)
return;

var relPath = Path.GetFileName(templatePath);
foreach (var blockName in duplicates)
{
var count = blocks.Count(b => string.Equals(b, blockName, StringComparison.OrdinalIgnoreCase));
_logger.LogWarning("Template file '{TemplatePath}' has {Count} definitions of block '{BlockName}' " +
"(only the last one will be used). Consolidate them into a single block.",
relPath, count, blockName);
}
}

/// <summary>
/// Extracts all block names from a Scriban template using regex.
/// Matches `{% block name %}...{% endblock %}` patterns (case-insensitive).
/// </summary>
private static List<string> ExtractBlockNames(string templateContent)
{
var result = new List<string>();
// Match {% block <name> %} — capture the block name
var pattern = @"{%\s*block\s+(\w+)\s*%}";
var matches = Regex.Matches(templateContent, pattern, RegexOptions.IgnoreCase);

foreach (Match match in matches)
{
if (match.Groups[1].Value is { Length: > 0 } blockName)
result.Add(blockName);
}

return result;
}
}
10 changes: 8 additions & 2 deletions src/Netdocs.Core/Templating/TemplateEngine.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Scriban;

Check failure on line 1 in src/Netdocs.Core/Templating/TemplateEngine.cs

View workflow job for this annotation

GitHub Actions / build

Fix imports ordering.
using Scriban.Runtime;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Netdocs.Core.Templating;

Expand All @@ -12,11 +14,13 @@
private readonly IReadOnlyList<string> _searchDirs;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, Template> _cache = new(StringComparer.OrdinalIgnoreCase);
private readonly ThemeTemplateLoader _loader;
private readonly TemplateBlockValidator _blockValidator;

public TemplateEngine(IEnumerable<string> searchDirsHighestPriorityFirst)
public TemplateEngine(IEnumerable<string> searchDirsHighestPriorityFirst, ILogger? logger = null)
{
_searchDirs = searchDirsHighestPriorityFirst.Where(Directory.Exists).ToList();
_loader = new ThemeTemplateLoader(_searchDirs);
_blockValidator = new TemplateBlockValidator(logger ?? NullLogger.Instance);
}

public bool TryResolve(string templateName, out string path) => _loader.TryResolvePath(templateName, out path);
Expand All @@ -43,7 +47,9 @@
{
if (!_loader.TryResolvePath(key, out var path))
throw new FileNotFoundException($"Template '{key}' not found in: {string.Join(", ", _searchDirs)}");
var template = Template.Parse(File.ReadAllText(path), path);
var content = File.ReadAllText(path);
_blockValidator.Validate(path, content);
var template = Template.Parse(content, path);
if (template.HasErrors)
throw new InvalidOperationException($"Template '{key}' has errors:\n{string.Join('\n', template.Messages)}");
return template;
Expand Down
79 changes: 79 additions & 0 deletions tests/Netdocs.Core.Tests/TemplateBlockValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace Netdocs.Core.Tests;

public class TemplateBlockValidatorTests
{
private sealed class TestLogger : ILogger
{
public List<string> Warnings { get; } = new();

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning)
Warnings.Add(formatter(state, exception));
}
}

[Fact]
public void DetectsNoDuplicates_EmitsNoWarning()
{
var logger = new TestLogger();
var validator = new Netdocs.Core.Templating.TemplateBlockValidator(logger);
var content = "{% block header %}...{% endblock %}\n{% block content %}...{% endblock %}";

validator.Validate("test.html", content);

Assert.Empty(logger.Warnings);
}

[Fact]
public void DetectsDuplicateBlocks_EmitsWarning()
{
var logger = new TestLogger();
var validator = new Netdocs.Core.Templating.TemplateBlockValidator(logger);
var content = "{% block header %}first{% endblock %}\n{% block header %}second{% endblock %}";

validator.Validate("test.html", content);

Assert.Single(logger.Warnings);
Assert.Contains("header", logger.Warnings[0]);
Assert.Contains("2 definitions", logger.Warnings[0]);
}

[Fact]
public void DetectsMultipleDuplicates_EmitsWarningPerBlock()
{
var logger = new TestLogger();
var validator = new Netdocs.Core.Templating.TemplateBlockValidator(logger);
var content = @"
{% block header %}a{% endblock %}
{% block header %}b{% endblock %}
{% block footer %}x{% endblock %}
{% block footer %}y{% endblock %}
{% block footer %}z{% endblock %}
";

validator.Validate("test.html", content);

Assert.Equal(2, logger.Warnings.Count);
Assert.Contains("header", logger.Warnings[0]);
Assert.Contains("footer", logger.Warnings[1]);
}

[Fact]
public void CaseInsensitive_TreatsHeaderAndHEADERAsIdentical()
{
var logger = new TestLogger();
var validator = new Netdocs.Core.Templating.TemplateBlockValidator(logger);
var content = "{% block header %}{% endblock %}\n{% block HEADER %}{% endblock %}";

validator.Validate("test.html", content);

Assert.Single(logger.Warnings);
}
}
Loading