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); + } +}