diff --git a/src/Netdocs.Core/Templating/TemplateBlockValidator.cs b/src/Netdocs.Core/Templating/TemplateBlockValidator.cs
new file mode 100644
index 0000000..8ef0044
--- /dev/null
+++ b/src/Netdocs.Core/Templating/TemplateBlockValidator.cs
@@ -0,0 +1,63 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+
+namespace Netdocs.Core.Templating;
+
+///
+/// Validates template files for duplicate block definitions, which silently override
+/// and can cause subtle bugs. Emits warnings for each duplicate found.
+///
+public sealed class TemplateBlockValidator
+{
+ private readonly ILogger _logger;
+
+ public TemplateBlockValidator(ILogger logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ /// Validates a template file for duplicate block definitions.
+ /// Absolute path to the template file.
+ /// Content of the template.
+ 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);
+ }
+ }
+
+ ///
+ /// Extracts all block names from a Scriban template using regex.
+ /// Matches `{% block name %}...{% endblock %}` patterns (case-insensitive).
+ ///
+ private static List ExtractBlockNames(string templateContent)
+ {
+ var result = new List();
+ // Match {% block %} — 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;
+ }
+}
diff --git a/src/Netdocs.Core/Templating/TemplateEngine.cs b/src/Netdocs.Core/Templating/TemplateEngine.cs
index e566089..9d04844 100644
--- a/src/Netdocs.Core/Templating/TemplateEngine.cs
+++ b/src/Netdocs.Core/Templating/TemplateEngine.cs
@@ -1,5 +1,7 @@
using Scriban;
using Scriban.Runtime;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace Netdocs.Core.Templating;
@@ -12,11 +14,13 @@ public sealed class TemplateEngine
private readonly IReadOnlyList _searchDirs;
private readonly System.Collections.Concurrent.ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase);
private readonly ThemeTemplateLoader _loader;
+ private readonly TemplateBlockValidator _blockValidator;
- public TemplateEngine(IEnumerable searchDirsHighestPriorityFirst)
+ public TemplateEngine(IEnumerable 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);
@@ -43,7 +47,9 @@ private Template GetTemplate(string name)
{
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;
diff --git a/tests/Netdocs.Core.Tests/TemplateBlockValidatorTests.cs b/tests/Netdocs.Core.Tests/TemplateBlockValidatorTests.cs
new file mode 100644
index 0000000..f3d44e5
--- /dev/null
+++ b/tests/Netdocs.Core.Tests/TemplateBlockValidatorTests.cs
@@ -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 Warnings { get; } = new();
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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);
+ }
+}