diff --git a/src/ScissorHands.Theme/CascadingMainLayoutBase.razor b/src/ScissorHands.Theme/CascadingMainLayoutBase.razor index fa87c31..e09b5cc 100644 --- a/src/ScissorHands.Theme/CascadingMainLayoutBase.razor +++ b/src/ScissorHands.Theme/CascadingMainLayoutBase.razor @@ -3,7 +3,15 @@ - @ChildContent + + + + + @ChildContent + + + + @@ -23,6 +31,34 @@ [Parameter] public IEnumerable? Documents { get; set; } + /// + /// Gets or sets the dictionary of tags and their associated documents. + /// Used for tag list view. + /// + [Parameter] + public IDictionary Posts, IEnumerable Pages)>? TaggedDocuments { get; set; } + + /// + /// Gets or sets the current tag name. + /// Used for individual tag view. + /// + [Parameter] + public string? Tag { get; set; } + + /// + /// Gets or sets the posts for the current tag. + /// Used for individual tag view. + /// + [Parameter] + public IEnumerable? TaggedPosts { get; set; } + + /// + /// Gets or sets the pages for the current tag. + /// Used for individual tag view. + /// + [Parameter] + public IEnumerable? TaggedPages { get; set; } + /// /// Gets or sets the current instance. /// diff --git a/src/ScissorHands.Theme/MainLayoutBase.cs b/src/ScissorHands.Theme/MainLayoutBase.cs index 6d05b4b..541db42 100644 --- a/src/ScissorHands.Theme/MainLayoutBase.cs +++ b/src/ScissorHands.Theme/MainLayoutBase.cs @@ -32,6 +32,34 @@ public abstract class MainLayoutBase : LayoutComponentBase [Parameter] public IEnumerable? Documents { get; set; } + /// + /// Gets or sets the dictionary of tags and their associated documents. + /// Used for tag list view. + /// + [Parameter] + public IDictionary Posts, IEnumerable Pages)>? TaggedDocuments { get; set; } + + /// + /// Gets or sets the current tag name. + /// Used for individual tag view. + /// + [Parameter] + public string? Tag { get; set; } + + /// + /// Gets or sets the posts for the current tag. + /// Used for individual tag view. + /// + [Parameter] + public IEnumerable? TaggedPosts { get; set; } + + /// + /// Gets or sets the pages for the current tag. + /// Used for individual tag view. + /// + [Parameter] + public IEnumerable? TaggedPages { get; set; } + /// /// Gets or sets the instance. /// diff --git a/src/ScissorHands.Theme/TagListViewBase.cs b/src/ScissorHands.Theme/TagListViewBase.cs new file mode 100644 index 0000000..ee5e8e0 --- /dev/null +++ b/src/ScissorHands.Theme/TagListViewBase.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Components; + +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Theme; + +/// +/// This represents the base class entity for the tag list view component. +/// This view displays all tags with their associated posts and pages. +/// +public abstract class TagListViewBase : ComponentBase +{ + /// + /// Gets or sets the dictionary of tags and their associated documents. + /// The key is the tag name, and the value is the tuple of posts and pages. + /// + [CascadingParameter(Name = "TaggedDocuments")] + public IDictionary Posts, IEnumerable Pages)>? TaggedDocuments { get; set; } + + /// + /// Gets or sets the list of instances. + /// + [CascadingParameter] + public IEnumerable? Plugins { get; set; } + + /// + /// Gets or sets the instance. + /// + [CascadingParameter] + public ThemeManifest? Theme { get; set; } + + /// + /// Gets or sets the instance. + /// + [CascadingParameter] + public SiteManifest? Site { get; set; } +} diff --git a/src/ScissorHands.Theme/TagViewBase.cs b/src/ScissorHands.Theme/TagViewBase.cs new file mode 100644 index 0000000..5413c82 --- /dev/null +++ b/src/ScissorHands.Theme/TagViewBase.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Components; + +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Theme; + +/// +/// This represents the base class entity for the tag view component. +/// This view displays all posts and pages for a specific tag. +/// +public abstract class TagViewBase : ComponentBase +{ + /// + /// Gets or sets the tag name. + /// + [CascadingParameter(Name = "Tag")] + public string? Tag { get; set; } + + /// + /// Gets or sets the list of posts for this tag. + /// + [CascadingParameter(Name = "TaggedPosts")] + public IEnumerable? TaggedPosts { get; set; } + + /// + /// Gets or sets the list of pages for this tag. + /// + [CascadingParameter(Name = "TaggedPages")] + public IEnumerable? TaggedPages { get; set; } + + /// + /// Gets or sets the list of instances. + /// + [CascadingParameter] + public IEnumerable? Plugins { get; set; } + + /// + /// Gets or sets the instance. + /// + [CascadingParameter] + public ThemeManifest? Theme { get; set; } + + /// + /// Gets or sets the instance. + /// + [CascadingParameter] + public SiteManifest? Site { get; set; } +} diff --git a/src/ScissorHands.Web/Generators/IStaticSiteGenerator.cs b/src/ScissorHands.Web/Generators/IStaticSiteGenerator.cs index 2d8db41..7825c3f 100644 --- a/src/ScissorHands.Web/Generators/IStaticSiteGenerator.cs +++ b/src/ScissorHands.Web/Generators/IStaticSiteGenerator.cs @@ -15,13 +15,17 @@ public interface IStaticSiteGenerator /// Type of the post view component. /// Type of the page view component. /// Type of the not found (404) view component. + /// Type of the tag list view component. + /// Type of the individual tag view component. /// The destination directory store the generated contents. /// Indicates whether to generate a preview version. /// A token to monitor for cancellation requests. - Task BuildAsync(string destination, bool preview, CancellationToken cancellationToken) + Task BuildAsync(string destination, bool preview, CancellationToken cancellationToken) where TMainLayout : MainLayoutBase where TIndexView : IndexViewBase where TPostView : PostViewBase where TPageView : PageViewBase - where TNotFoundView : NotFoundViewBase; + where TNotFoundView : NotFoundViewBase + where TTagListView : TagListViewBase + where TTagView : TagViewBase; } diff --git a/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs b/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs index 7f511de..ee6d674 100644 --- a/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs +++ b/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs @@ -48,12 +48,14 @@ public sealed class StaticSiteGenerator( private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); /// - public async Task BuildAsync(string destination, bool preview, CancellationToken cancellationToken) + public async Task BuildAsync(string destination, bool preview, CancellationToken cancellationToken) where TMainLayout : ScissorHands.Theme.MainLayoutBase where TIndexView : ScissorHands.Theme.IndexViewBase where TPostView : ScissorHands.Theme.PostViewBase where TPageView : ScissorHands.Theme.PageViewBase where TNotFoundView : ScissorHands.Theme.NotFoundViewBase + where TTagListView : ScissorHands.Theme.TagListViewBase + where TTagView : ScissorHands.Theme.TagViewBase { _fileSystem.Directory.CreateDirectory(destination); _logger.LogInformation("Starting static site build to {Destination} (preview: {Preview})", destination, preview); @@ -74,6 +76,8 @@ public async Task BuildAsync(document, plugins, theme, destination, layoutType, cancellationToken); } + await RenderTagPagesAsync(documents, plugins, theme, destination, layoutType, cancellationToken); + CopyContentAssets(destination); await _themeService.CopyAssetsAsync(_options.Theme, destination); } @@ -86,27 +90,20 @@ private async Task RenderIndexAsync(IEnumerable doc .OrderByDescending(d => d.Metadata.Published ?? DateTimeOffset.MinValue) .ToList(); - var parameters = new Dictionary - { - ["Documents"] = posts, - ["Plugins"] = plugins, - ["Theme"] = theme, - ["Site"] = _options - }; + var parameters = CreateBaseParameters(plugins, theme); + parameters["Documents"] = posts; var rendered = await _renderer.RenderAsync(layoutType, parameters, cancellationToken); - var finalHtml = await _pluginRunner.RunPostHtmlAsync(rendered, new ContentDocument + var indexDocument = new ContentDocument { Kind = ContentKind.Page, Metadata = new ContentMetadata { Title = _options.Title, Slug = string.Empty }, Markdown = string.Empty, Html = rendered - }, cancellationToken); + }; var outputPath = ResolveOutputPath(destination, string.Empty); - _fileSystem.Directory.CreateDirectory(_fileSystem.Path.GetDirectoryName(outputPath)!); - await _fileSystem.File.WriteAllTextAsync(outputPath, finalHtml, Encoding.UTF8, cancellationToken); - _logger.LogInformation("Wrote {OutputPath}", outputPath); + await WriteRenderedHtmlAsync(outputPath, rendered, indexDocument, cancellationToken); } private async Task RenderNotFoundAsync(ContentDocument? notFoundDocument, IEnumerable plugins, ThemeManifest theme, string destination, Type layoutType, CancellationToken cancellationToken) @@ -125,10 +122,7 @@ private async Task RenderNotFoundAsync(ContentDocument? notFoundD } else { - var preProcessed = await _pluginRunner.RunPreMarkdownAsync(notFoundDocument, cancellationToken); - var html = await _markdownService.ToHtmlAsync(preProcessed.Markdown, cancellationToken: cancellationToken); - preProcessed.Html = html; - documentToRender = await _pluginRunner.RunPostMarkdownAsync(preProcessed, cancellationToken); + documentToRender = await ConvertMarkdownToHtmlAsync(notFoundDocument, cancellationToken); if (string.IsNullOrWhiteSpace(documentToRender.Metadata.Title)) { @@ -143,21 +137,13 @@ private async Task RenderNotFoundAsync(ContentDocument? notFoundD } } - var parameters = new Dictionary - { - ["Document"] = documentToRender, - ["Plugins"] = plugins, - ["Theme"] = theme, - ["Site"] = _options - }; + var parameters = CreateBaseParameters(plugins, theme); + parameters["Document"] = documentToRender; var rendered = await _renderer.RenderAsync(layoutType, parameters, cancellationToken); - var finalHtml = await _pluginRunner.RunPostHtmlAsync(rendered, documentToRender, cancellationToken); var outputPath = _fileSystem.Path.Combine(destination, PAGE_NOT_FOUND_SLUG); - _fileSystem.Directory.CreateDirectory(_fileSystem.Path.GetDirectoryName(outputPath)!); - await _fileSystem.File.WriteAllTextAsync(outputPath, finalHtml, Encoding.UTF8, cancellationToken); - _logger.LogInformation("Wrote {OutputPath}", outputPath); + await WriteRenderedHtmlAsync(outputPath, rendered, documentToRender, cancellationToken); } private async Task RenderDocumentAsync(ContentDocument document, IEnumerable plugins, ThemeManifest theme, string destination, Type layoutType, CancellationToken cancellationToken) @@ -166,47 +152,131 @@ private async Task RenderDocumentAsync(ContentDocument doc { cancellationToken.ThrowIfCancellationRequested(); - var preProcessed = await _pluginRunner.RunPreMarkdownAsync(document, cancellationToken); - var html = await _markdownService.ToHtmlAsync(preProcessed.Markdown, cancellationToken: cancellationToken); - preProcessed.Html = html; - var postMarkdown = await _pluginRunner.RunPostMarkdownAsync(preProcessed, cancellationToken); + var postMarkdown = await ConvertMarkdownToHtmlAsync(document, cancellationToken); - var parameters = new Dictionary - { - ["Document"] = postMarkdown, - ["Plugins"] = plugins, - ["Theme"] = theme, - ["Site"] = _options - }; + var parameters = CreateBaseParameters(plugins, theme); + parameters["Document"] = postMarkdown; var rendered = postMarkdown.Kind switch { ContentKind.Page => await _renderer.RenderAsync(layoutType, parameters, cancellationToken), _ => await _renderer.RenderAsync(layoutType, parameters, cancellationToken) }; - - var finalHtml = await _pluginRunner.RunPostHtmlAsync(rendered, postMarkdown, cancellationToken); var outputPath = ResolveOutputPath(destination, postMarkdown.Metadata.Slug); - _fileSystem.Directory.CreateDirectory(_fileSystem.Path.GetDirectoryName(outputPath)!); - await _fileSystem.File.WriteAllTextAsync(outputPath, finalHtml, Encoding.UTF8, cancellationToken); - _logger.LogInformation("Wrote {OutputPath}", outputPath); + + await WriteRenderedHtmlAsync(outputPath, rendered, postMarkdown, cancellationToken); } - private static bool IsNotFoundPage(ContentDocument document) + private async Task RenderTagPagesAsync(IEnumerable documents, IEnumerable plugins, ThemeManifest theme, string destination, Type layoutType, CancellationToken cancellationToken) + where TTagListView : ScissorHands.Theme.TagListViewBase + where TTagView : ScissorHands.Theme.TagViewBase { - return document.Kind == ContentKind.Page && - string.Equals(document.Metadata.Slug, PAGE_NOT_FOUND_SLUG, StringComparison.OrdinalIgnoreCase) == true; + // Build the tag dictionary: for each tag, group posts (sorted by published date descending) and pages (sorted by title ascending) + var taggedDocuments = documents + .Where(d => d.Metadata.Tags.Any() && IsNotFoundPage(d) == false) + .SelectMany(d => d.Metadata.Tags.Select(tag => (Tag: tag.ToLowerInvariant(), Document: d))) + .GroupBy(x => x.Tag) + .ToDictionary( + g => g.Key, + g => + { + var posts = g + .Where(x => x.Document.Kind == ContentKind.Post) + .Select(x => x.Document) + .OrderByDescending(d => d.Metadata.Published ?? DateTimeOffset.MinValue) + .ToList() + .AsEnumerable(); + + var pages = g + .Where(x => x.Document.Kind == ContentKind.Page) + .Select(x => x.Document) + .OrderBy(d => d.Metadata.Title) + .ToList() + .AsEnumerable(); + + return (Posts: posts, Pages: pages); + }); + + if (taggedDocuments.Count == 0) + { + _logger.LogInformation("No tags found in content documents; skipping tag pages"); + return; + } + + // Render the tag list page at /tags + await RenderTagListPageAsync(taggedDocuments, plugins, theme, destination, layoutType, cancellationToken); + + // Render individual tag pages at /tags/{tag} + foreach (var tagEntry in taggedDocuments) + { + await RenderTagPageAsync(tagEntry.Key, tagEntry.Value.Posts, tagEntry.Value.Pages, plugins, theme, destination, layoutType, cancellationToken); + } } - private static string ResolveOutputPath(string root, string slug) + private async Task RenderTagListPageAsync(IDictionary Posts, IEnumerable Pages)> taggedDocuments, IEnumerable plugins, ThemeManifest theme, string destination, Type layoutType, CancellationToken cancellationToken) + where TTagListView : ScissorHands.Theme.TagListViewBase { - if (string.IsNullOrWhiteSpace(slug)) + var parameters = CreateBaseParameters(plugins, theme); + parameters["TaggedDocuments"] = taggedDocuments; + + var rendered = await _renderer.RenderAsync(layoutType, parameters, cancellationToken); + var tagListDocument = new ContentDocument { - return Path.Combine(root, "index.html"); - } + Kind = ContentKind.Page, + Metadata = new ContentMetadata { Title = "Tags", Slug = "tags" }, + Markdown = string.Empty, + Html = rendered + }; + var outputPath = ResolveOutputPath(destination, "tags"); + await WriteRenderedHtmlAsync(outputPath, rendered, tagListDocument, cancellationToken); + } - var safeSlug = slug.Trim('/'); - return Path.Combine(root, safeSlug, "index.html"); + private async Task RenderTagPageAsync(string tag, IEnumerable posts, IEnumerable pages, IEnumerable plugins, ThemeManifest theme, string destination, Type layoutType, CancellationToken cancellationToken) + where TTagView : ScissorHands.Theme.TagViewBase + { + var parameters = CreateBaseParameters(plugins, theme); + parameters["Tag"] = tag; + parameters["TaggedPosts"] = posts; + parameters["TaggedPages"] = pages; + + var rendered = await _renderer.RenderAsync(layoutType, parameters, cancellationToken); + var tagDocument = new ContentDocument + { + Kind = ContentKind.Page, + Metadata = new ContentMetadata { Title = $"Tag: {tag}", Slug = $"tags/{tag}" }, + Markdown = string.Empty, + Html = rendered + }; + var outputPath = ResolveOutputPath(destination, $"tags/{tag}"); + await WriteRenderedHtmlAsync(outputPath, rendered, tagDocument, cancellationToken); + } + + private Dictionary CreateBaseParameters(IEnumerable plugins, ThemeManifest theme) + { + return new Dictionary + { + ["Plugins"] = plugins, + ["Theme"] = theme, + ["Site"] = _options + }; + } + + private async Task WriteRenderedHtmlAsync(string outputPath, string renderedHtml, ContentDocument document, CancellationToken cancellationToken) + { + var finalHtml = await _pluginRunner.RunPostHtmlAsync(renderedHtml, document, cancellationToken); + + _fileSystem.Directory.CreateDirectory(_fileSystem.Path.GetDirectoryName(outputPath)!); + await _fileSystem.File.WriteAllTextAsync(outputPath, finalHtml, Encoding.UTF8, cancellationToken); + _logger.LogInformation("Wrote {OutputPath}", outputPath); + } + + private async Task ConvertMarkdownToHtmlAsync(ContentDocument document, CancellationToken cancellationToken) + { + var preProcessed = await _pluginRunner.RunPreMarkdownAsync(document, cancellationToken); + var html = await _markdownService.ToHtmlAsync(preProcessed.Markdown, cancellationToken: cancellationToken); + preProcessed.Html = html; + + return await _pluginRunner.RunPostMarkdownAsync(preProcessed, cancellationToken); } private void CopyContentAssets(string destination) @@ -239,4 +309,21 @@ private void CopyDirectory(string sourceDir, string destinationDir) CopyDirectory(directory, _fileSystem.Path.Combine(destinationDir, name)); } } + + private static bool IsNotFoundPage(ContentDocument document) + { + return document.Kind == ContentKind.Page && + string.Equals(document.Metadata.Slug, PAGE_NOT_FOUND_SLUG, StringComparison.OrdinalIgnoreCase) == true; + } + + private static string ResolveOutputPath(string root, string slug) + { + if (string.IsNullOrWhiteSpace(slug)) + { + return Path.Combine(root, "index.html"); + } + + var safeSlug = slug.Trim('/'); + return Path.Combine(root, safeSlug, "index.html"); + } } diff --git a/src/ScissorHands.Web/ScissorHandsApplication.cs b/src/ScissorHands.Web/ScissorHandsApplication.cs index 9ef3cfa..131acdf 100644 --- a/src/ScissorHands.Web/ScissorHandsApplication.cs +++ b/src/ScissorHands.Web/ScissorHandsApplication.cs @@ -33,7 +33,7 @@ public interface IScissorHandsApplication public sealed class ScissorHandsApplication : IScissorHandsApplication { private const string APP_LOGGER_NAME = "App"; - private const int EXPECTED_GENERIC_PARAMETER_COUNT = 5; + private const int EXPECTED_GENERIC_PARAMETER_COUNT = 7; private const int EXPECTED_METHOD_PARAMETER_COUNT = 3; private readonly string[] _args; @@ -42,6 +42,8 @@ public sealed class ScissorHandsApplication : IScissorHandsApplication private readonly Type _postView; private readonly Type _pageView; private readonly Type _notFoundView; + private readonly Type _tagListView; + private readonly Type _tagView; private CommandMode _mode; private readonly WebApplication _app; private ILogger? _logger; @@ -50,7 +52,7 @@ public sealed class ScissorHandsApplication : IScissorHandsApplication private MethodInfo? _cachedBuildMethod; private readonly object _cacheLock = new object(); - internal ScissorHandsApplication(WebApplication app, IEnumerable args, Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView) + internal ScissorHandsApplication(WebApplication app, IEnumerable args, Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView, Type tagListView, Type tagView) { _app = app ?? throw new ArgumentNullException(nameof(app)); _args = args?.ToArray() ?? throw new ArgumentNullException(nameof(args)); @@ -59,6 +61,8 @@ internal ScissorHandsApplication(WebApplication app, IEnumerable args, T _postView = postView ?? throw new ArgumentNullException(nameof(postView)); _pageView = pageView ?? throw new ArgumentNullException(nameof(pageView)); _notFoundView = notFoundView ?? throw new ArgumentNullException(nameof(notFoundView)); + _tagListView = tagListView ?? throw new ArgumentNullException(nameof(tagListView)); + _tagView = tagView ?? throw new ArgumentNullException(nameof(tagView)); } private void VerifyCommandArguments() @@ -232,7 +236,7 @@ private Task BuildSiteAsync(string destination, bool preview, CancellationToken } } - var closedMethod = _cachedBuildMethod.MakeGenericMethod(_mainLayout, _indexView, _postView, _pageView, _notFoundView); + var closedMethod = _cachedBuildMethod.MakeGenericMethod(_mainLayout, _indexView, _postView, _pageView, _notFoundView, _tagListView, _tagView); var parameters = new object[] { destination, preview, cancellationToken }; var task = (Task?)closedMethod.Invoke(_generator, parameters); diff --git a/src/ScissorHands.Web/ScissorHandsApplicationBuilder.cs b/src/ScissorHands.Web/ScissorHandsApplicationBuilder.cs index e83ad03..47b4266 100644 --- a/src/ScissorHands.Web/ScissorHandsApplicationBuilder.cs +++ b/src/ScissorHands.Web/ScissorHandsApplicationBuilder.cs @@ -19,13 +19,17 @@ public interface IScissorHandsApplicationBuilder /// Type of the post view. /// Type of the page view. /// Type of the not found view. + /// Type of the tag list view. + /// Type of the individual tag view. /// Returns instance. - IScissorHandsApplicationBuilder AddLayouts() + IScissorHandsApplicationBuilder AddLayouts() where TMainLayout : MainLayoutBase where TIndexView : IndexViewBase where TPostView : PostViewBase where TPageView : PageViewBase - where TNotFoundView : NotFoundViewBase; + where TNotFoundView : NotFoundViewBase + where TTagListView : TagListViewBase + where TTagView : TagViewBase; /// /// Add layouts to the application. @@ -35,8 +39,10 @@ IScissorHandsApplicationBuilder AddLayoutsType of the post view. /// Type of the page view. /// Type of the not found view. + /// Type of the tag list view. + /// Type of the individual tag view. /// Returns instance. - IScissorHandsApplicationBuilder AddLayouts(Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView); + IScissorHandsApplicationBuilder AddLayouts(Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView, Type tagListView, Type tagView); /// /// Builds the application. @@ -57,38 +63,48 @@ public sealed class ScissorHandsApplicationBuilder(IEnumerable? args = n private Type? _postView; private Type? _pageView; private Type? _notFoundView; + private Type? _tagListView; + private Type? _tagView; /// - public IScissorHandsApplicationBuilder AddLayouts() + public IScissorHandsApplicationBuilder AddLayouts() where TMainLayout : MainLayoutBase where TIndexView : IndexViewBase where TPostView : PostViewBase where TPageView : PageViewBase where TNotFoundView : NotFoundViewBase + where TTagListView : TagListViewBase + where TTagView : TagViewBase { - return AddLayouts(typeof(TMainLayout), typeof(TIndexView), typeof(TPostView), typeof(TPageView), typeof(TNotFoundView)); + return AddLayouts(typeof(TMainLayout), typeof(TIndexView), typeof(TPostView), typeof(TPageView), typeof(TNotFoundView), typeof(TTagListView), typeof(TTagView)); } /// - public IScissorHandsApplicationBuilder AddLayouts(Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView) + public IScissorHandsApplicationBuilder AddLayouts(Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView, Type tagListView, Type tagView) { ArgumentNullException.ThrowIfNull(mainLayout); ArgumentNullException.ThrowIfNull(indexView); ArgumentNullException.ThrowIfNull(postView); ArgumentNullException.ThrowIfNull(pageView); ArgumentNullException.ThrowIfNull(notFoundView); + ArgumentNullException.ThrowIfNull(tagListView); + ArgumentNullException.ThrowIfNull(tagView); EnsureAssignableTo(mainLayout, nameof(mainLayout)); EnsureAssignableTo(indexView, nameof(indexView)); EnsureAssignableTo(postView, nameof(postView)); EnsureAssignableTo(pageView, nameof(pageView)); EnsureAssignableTo(notFoundView, nameof(notFoundView)); + EnsureAssignableTo(tagListView, nameof(tagListView)); + EnsureAssignableTo(tagView, nameof(tagView)); _mainLayout = mainLayout; _indexView = indexView; _postView = postView; _pageView = pageView; _notFoundView = notFoundView; + _tagListView = tagListView; + _tagView = tagView; return this; } @@ -96,7 +112,7 @@ public IScissorHandsApplicationBuilder AddLayouts(Type mainLayout, Type indexVie /// public IScissorHandsApplication Build() { - if (_mainLayout is null || _indexView is null || _postView is null || _pageView is null || _notFoundView is null) + if (_mainLayout is null || _indexView is null || _postView is null || _pageView is null || _notFoundView is null || _tagListView is null || _tagView is null) { throw new InvalidOperationException("Layouts are not configured. Call AddLayouts(...) before Build()."); } @@ -111,7 +127,7 @@ public IScissorHandsApplication Build() var app = builder.Build(); - return new ScissorHandsApplication(app, _args, _mainLayout, _indexView, _postView, _pageView, _notFoundView); + return new ScissorHandsApplication(app, _args, _mainLayout, _indexView, _postView, _pageView, _notFoundView, _tagListView, _tagView); } private static void EnsureAssignableTo(Type type, string paramName) diff --git a/src/ScissorHands.Web/themes/default/IndexView.razor b/src/ScissorHands.Web/themes/default/IndexView.razor index 32fb6d3..93028fb 100644 --- a/src/ScissorHands.Web/themes/default/IndexView.razor +++ b/src/ScissorHands.Web/themes/default/IndexView.razor @@ -21,6 +21,12 @@

@post.Metadata.Description

} +
    + @foreach (var tag in post.Metadata.Tags ?? Array.Empty()) + { +
  • @tag
  • + } +
} } diff --git a/src/ScissorHands.Web/themes/default/MainLayout.razor b/src/ScissorHands.Web/themes/default/MainLayout.razor index c1786c1..0ce68fb 100644 --- a/src/ScissorHands.Web/themes/default/MainLayout.razor +++ b/src/ScissorHands.Web/themes/default/MainLayout.razor @@ -1,6 +1,6 @@ @inherits ScissorHands.Theme.MainLayoutBase - + diff --git a/src/ScissorHands.Web/themes/default/PostView.razor b/src/ScissorHands.Web/themes/default/PostView.razor index 792c95f..eff9db5 100644 --- a/src/ScissorHands.Web/themes/default/PostView.razor +++ b/src/ScissorHands.Web/themes/default/PostView.razor @@ -2,6 +2,12 @@ @layout MainLayout
+
    + @foreach (var tag in Document?.Metadata.Tags ?? Array.Empty()) + { +
  • @tag
  • + } +
@((MarkupString)Document?.Html!)
diff --git a/src/ScissorHands.Web/themes/default/TagListView.razor b/src/ScissorHands.Web/themes/default/TagListView.razor new file mode 100644 index 0000000..43500ef --- /dev/null +++ b/src/ScissorHands.Web/themes/default/TagListView.razor @@ -0,0 +1,49 @@ +@inherits ScissorHands.Theme.TagListViewBase +@layout MainLayout + +
+

Tags

+ @if (TaggedDocuments?.Any() != true) + { +

No tags found.

+ } + else + { + @foreach (var tagEntry in TaggedDocuments!.OrderBy(t => t.Key)) + { +
+

+ @tagEntry.Key +

+ @if (tagEntry.Value.Posts.Any()) + { +

Posts

+
    + @foreach (var post in tagEntry.Value.Posts) + { +
  • + @post.Metadata.Title + @if (post.Metadata.Published.HasValue) + { + | @post.Metadata.Published.Value.ToString("yyyy-MM-dd") + } +
  • + } +
+ } + @if (tagEntry.Value.Pages.Any()) + { +

Pages

+ + } +
+ } + } +
diff --git a/src/ScissorHands.Web/themes/default/TagView.razor b/src/ScissorHands.Web/themes/default/TagView.razor new file mode 100644 index 0000000..126daf9 --- /dev/null +++ b/src/ScissorHands.Web/themes/default/TagView.razor @@ -0,0 +1,45 @@ +@inherits ScissorHands.Theme.TagViewBase +@layout MainLayout + +
+

Tag: @Tag

+ @if ((TaggedPosts?.Any() != true) && (TaggedPages?.Any() != true)) + { +

No content found for this tag.

+ } + else + { + @if (TaggedPosts?.Any() == true) + { +
+

Posts

+
    + @foreach (var post in TaggedPosts!) + { +
  • + @post.Metadata.Title + @if (post.Metadata.Published.HasValue) + { + | @post.Metadata.Published.Value.ToString("yyyy-MM-dd") + } +
  • + } +
+
+ } + @if (TaggedPages?.Any() == true) + { +
+

Pages

+ +
+ } + } +
diff --git a/test/ScissorHands.Theme.Tests/TagListViewBaseTests.cs b/test/ScissorHands.Theme.Tests/TagListViewBaseTests.cs new file mode 100644 index 0000000..280e253 --- /dev/null +++ b/test/ScissorHands.Theme.Tests/TagListViewBaseTests.cs @@ -0,0 +1,57 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Theme.Tests; + +public class TagListViewBaseTests +{ + [Fact] + public void Given_TagListViewBase_When_Constructed_Then_It_Should_HaveNullTaggedDocuments() + { + // Arrange + + // Act + var view = new TestTagListView(); + + // Assert + view.TaggedDocuments.ShouldBeNull(); + } + + [Fact] + public void Given_TagListViewBase_When_RenderedWithCascadingValues_Then_It_Should_BindCascadingParameters() + { + // Arrange + using var context = new BunitContext(); + + var documents = new List { new() }; + var plugins = new List { new() }; + var theme = new ThemeManifest { Name = "test", Slug = "test" }; + var site = new SiteManifest(); + + var taggedDocuments = new Dictionary Posts, IEnumerable Pages)> + { + ["tag"] = (new List { new() }, new List { new() }) + }; + + // Act + var cut = context.Renderer.Render(p => p + .Add(x => x.Documents, documents) + .Add(x => x.TaggedDocuments, taggedDocuments) + .Add(x => x.Plugins, plugins) + .Add(x => x.Theme, theme) + .Add(x => x.Site, site) + .AddChildContent()); + + var view = cut.FindComponent().Instance; + + // Assert + view.TaggedDocuments.ShouldBeSameAs(taggedDocuments); + view.Plugins.ShouldBeSameAs(plugins); + view.Theme.ShouldBeSameAs(theme); + view.Site.ShouldBeSameAs(site); + } +} + +internal class TestTagListView : TagListViewBase +{ +} diff --git a/test/ScissorHands.Theme.Tests/TagViewBaseTests.cs b/test/ScissorHands.Theme.Tests/TagViewBaseTests.cs new file mode 100644 index 0000000..6000304 --- /dev/null +++ b/test/ScissorHands.Theme.Tests/TagViewBaseTests.cs @@ -0,0 +1,65 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Theme.Tests; + +public class TagViewBaseTests +{ + [Fact] + public void Given_TagViewBase_When_Constructed_Then_It_Should_HaveNullCascadingParameters() + { + // Arrange + + // Act + var view = new TestTagView(); + + // Assert + view.Tag.ShouldBeNull(); + view.TaggedPosts.ShouldBeNull(); + view.TaggedPages.ShouldBeNull(); + view.Plugins.ShouldBeNull(); + view.Theme.ShouldBeNull(); + view.Site.ShouldBeNull(); + } + + [Fact] + public void Given_TagViewBase_When_RenderedWithCascadingValues_Then_It_Should_BindCascadingParameters() + { + // Arrange + using var context = new BunitContext(); + + var documents = new List { new() }; + var taggedPosts = new List { new() }; + var taggedPages = new List { new() }; + var plugins = new List { new() }; + var theme = new ThemeManifest { Name = "test", Slug = "test" }; + var site = new SiteManifest(); + + const string tag = "tag"; + + // Act + var cut = context.Renderer.Render(p => p + .Add(x => x.Documents, documents) + .Add(x => x.Tag, tag) + .Add(x => x.TaggedPosts, taggedPosts) + .Add(x => x.TaggedPages, taggedPages) + .Add(x => x.Plugins, plugins) + .Add(x => x.Theme, theme) + .Add(x => x.Site, site) + .AddChildContent()); + + var view = cut.FindComponent().Instance; + + // Assert + view.Tag.ShouldBe(tag); + view.TaggedPosts.ShouldBeSameAs(taggedPosts); + view.TaggedPages.ShouldBeSameAs(taggedPages); + view.Plugins.ShouldBeSameAs(plugins); + view.Theme.ShouldBeSameAs(theme); + view.Site.ShouldBeSameAs(site); + } +} + +internal class TestTagView : TagViewBase +{ +} diff --git a/test/ScissorHands.Web.Tests/Generators/StaticSiteGeneratorTests.cs b/test/ScissorHands.Web.Tests/Generators/StaticSiteGeneratorTests.cs index abcfa37..3ca538c 100644 --- a/test/ScissorHands.Web.Tests/Generators/StaticSiteGeneratorTests.cs +++ b/test/ScissorHands.Web.Tests/Generators/StaticSiteGeneratorTests.cs @@ -101,6 +101,14 @@ public async Task Given_ContentDocuments_When_BuildAsync_Invoked_Then_It_Should_ .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + var logger = Substitute.For>(); var generator = new StaticSiteGenerator( @@ -115,7 +123,7 @@ public async Task Given_ContentDocuments_When_BuildAsync_Invoked_Then_It_Should_ logger); // Act - await generator.BuildAsync(destination, preview: false, CancellationToken.None); + await generator.BuildAsync(destination, preview: false, CancellationToken.None); // Assert site.DescriptionInHtml.ShouldBe("HTML:My Description"); @@ -209,6 +217,12 @@ public async Task Given_404MarkdownPage_When_BuildAsync_Invoked_Then_It_Should_P renderer .RenderAsync(Arg.Any(), Arg.Do>(p => capturedNotFoundParams = p), Arg.Any()) .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); var logger = Substitute.For>(); @@ -224,7 +238,7 @@ public async Task Given_404MarkdownPage_When_BuildAsync_Invoked_Then_It_Should_P logger); // Act - await generator.BuildAsync(destination, preview: false, CancellationToken.None); + await generator.BuildAsync(destination, preview: false, CancellationToken.None); // Assert capturedNotFoundParams.ShouldNotBeNull(); @@ -302,6 +316,14 @@ public async Task Given_NoContentImagesFolder_When_BuildAsync_Invoked_Then_It_Sh .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + var logger = Substitute.For>(); var generator = new StaticSiteGenerator( @@ -316,7 +338,7 @@ public async Task Given_NoContentImagesFolder_When_BuildAsync_Invoked_Then_It_Sh logger); // Act - await generator.BuildAsync(destination, preview: false, CancellationToken.None); + await generator.BuildAsync(destination, preview: false, CancellationToken.None); // Assert fileSystem.Directory.Exists(fileSystem.Path.Combine(destination, "images")).ShouldBeFalse(); @@ -384,6 +406,14 @@ public async Task Given_ContentImages_When_BuildAsync_Invoked_Then_It_Should_Cop .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + var logger = Substitute.For>(); var generator = new StaticSiteGenerator( @@ -398,7 +428,7 @@ public async Task Given_ContentImages_When_BuildAsync_Invoked_Then_It_Should_Cop logger); // Act - await generator.BuildAsync(destination, preview: false, CancellationToken.None); + await generator.BuildAsync(destination, preview: false, CancellationToken.None); // Assert fileSystem.File.Exists(fileSystem.Path.Combine(destination, "images", "a.png")).ShouldBeTrue(); @@ -487,6 +517,14 @@ public async Task Given_PostsWithDifferentPublishDates_When_BuildAsync_Invoked_T .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + var logger = Substitute.For>(); var generator = new StaticSiteGenerator( @@ -501,7 +539,7 @@ public async Task Given_PostsWithDifferentPublishDates_When_BuildAsync_Invoked_T logger); // Act - await generator.BuildAsync(destination, preview: false, CancellationToken.None); + await generator.BuildAsync(destination, preview: false, CancellationToken.None); // Assert captured.ShouldNotBeNull(); @@ -513,37 +551,811 @@ public async Task Given_PostsWithDifferentPublishDates_When_BuildAsync_Invoked_T docs[0].Metadata.Slug.ShouldBe("blog/newer"); docs[1].Metadata.Slug.ShouldBe("blog/older"); } -} -internal sealed class TestMainLayout : ScissorHands.Theme.MainLayoutBase -{ - protected override void BuildRenderTree(RenderTreeBuilder builder) + [Fact] + public async Task Given_DocumentsWithTags_When_BuildAsync_Invoked_Then_It_Should_GenerateTagListAndTagPages() { - } -} + // Arrange + var fileSystem = new MockFileSystem(); + var root = fileSystem.Path.GetPathRoot(Environment.CurrentDirectory) ?? fileSystem.Path.DirectorySeparatorChar.ToString(); + var baseRoot = fileSystem.Path.Combine(root, "base"); + var contentsRoot = fileSystem.Path.Combine(baseRoot, "contents"); + var themesRoot = fileSystem.Path.Combine(baseRoot, "themes"); + var destination = fileSystem.Path.Combine(root, "out"); -internal sealed class TestIndexView : ScissorHands.Theme.IndexViewBase -{ - protected override void BuildRenderTree(RenderTreeBuilder builder) - { + var paths = new TestAppPaths(basePath: baseRoot, contentsRoot, themesRoot); + + var site = new SiteManifest + { + Title = "My Site", + Description = "My Description", + Theme = "minimal" + }; + + var post1 = new ContentDocument + { + Kind = ContentKind.Post, + Markdown = "# Post 1", + Metadata = new ContentMetadata + { + Title = "Hello World", + Slug = "blog/hello-world", + Published = new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero), + Tags = ["blazor", "azure"] + } + }; + + var post2 = new ContentDocument + { + Kind = ContentKind.Post, + Markdown = "# Post 2", + Metadata = new ContentMetadata + { + Title = "Lorem Ipsum", + Slug = "blog/lorem-ipsum", + Published = new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero), + Tags = ["asp-net", "azure"] + } + }; + + var page1 = new ContentDocument + { + Kind = ContentKind.Page, + Markdown = "# Getting Started", + Metadata = new ContentMetadata + { + Title = "Getting Started", + Slug = "getting-started", + Tags = ["blazor"] + } + }; + + var contentLoader = Substitute.For(); + contentLoader + .LoadAsync(Arg.Any()) + .Returns(Task.FromResult>(new[] { post1, post2, page1 })); + + var markdownService = Substitute.For(); + markdownService + .ToHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"HTML:{callInfo.ArgAt(0)}")); + + var pluginRunner = Substitute.For(); + pluginRunner.Manifests.Returns([]); + pluginRunner + .RunPreMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"FINAL:{callInfo.ArgAt(0)}")); + + var themeService = Substitute.For(); + themeService + .LoadManifestAsync(Arg.Any()) + .Returns(Task.FromResult(new ThemeManifest { Name = "Minimal", Slug = "minimal" })); + themeService + .CopyAssetsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var renderer = Substitute.For(); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("INDEX")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("POST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("PAGE")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + + var logger = Substitute.For>(); + + var generator = new StaticSiteGenerator( + contentLoader, + markdownService, + pluginRunner, + themeService, + renderer, + paths, + fileSystem, + site, + logger); + + // Act + await generator.BuildAsync(destination, preview: false, CancellationToken.None); + + // Assert - tag list page at /tags + fileSystem.File.Exists(fileSystem.Path.Combine(destination, "tags", "index.html")).ShouldBeTrue(); + fileSystem.File.ReadAllText(fileSystem.Path.Combine(destination, "tags", "index.html")).ShouldBe("FINAL:TAGLIST"); + + // Assert - individual tag pages + fileSystem.File.Exists(fileSystem.Path.Combine(destination, "tags", "blazor", "index.html")).ShouldBeTrue(); + fileSystem.File.ReadAllText(fileSystem.Path.Combine(destination, "tags", "blazor", "index.html")).ShouldBe("FINAL:TAG"); + + fileSystem.File.Exists(fileSystem.Path.Combine(destination, "tags", "azure", "index.html")).ShouldBeTrue(); + fileSystem.File.ReadAllText(fileSystem.Path.Combine(destination, "tags", "azure", "index.html")).ShouldBe("FINAL:TAG"); + + fileSystem.File.Exists(fileSystem.Path.Combine(destination, "tags", "asp-net", "index.html")).ShouldBeTrue(); + fileSystem.File.ReadAllText(fileSystem.Path.Combine(destination, "tags", "asp-net", "index.html")).ShouldBe("FINAL:TAG"); } -} -internal sealed class TestPostView : ScissorHands.Theme.PostViewBase -{ - protected override void BuildRenderTree(RenderTreeBuilder builder) + [Fact] + public async Task Given_DocumentsWithTags_When_BuildAsync_Invoked_Then_TagListView_Should_ReceiveCorrectParameters() { + // Arrange + var fileSystem = new MockFileSystem(); + var root = fileSystem.Path.GetPathRoot(Environment.CurrentDirectory) ?? fileSystem.Path.DirectorySeparatorChar.ToString(); + var baseRoot = fileSystem.Path.Combine(root, "base"); + var contentsRoot = fileSystem.Path.Combine(baseRoot, "contents"); + var themesRoot = fileSystem.Path.Combine(baseRoot, "themes"); + var destination = fileSystem.Path.Combine(root, "out"); + + var paths = new TestAppPaths(basePath: baseRoot, contentsRoot, themesRoot); + + var site = new SiteManifest + { + Title = "My Site", + Description = "My Description", + Theme = "minimal" + }; + + var post1 = new ContentDocument + { + Kind = ContentKind.Post, + Markdown = "# Post 1", + Metadata = new ContentMetadata + { + Title = "Hello World", + Slug = "blog/hello-world", + Published = new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero), + Tags = ["blazor", "azure"] + } + }; + + var post2 = new ContentDocument + { + Kind = ContentKind.Post, + Markdown = "# Post 2", + Metadata = new ContentMetadata + { + Title = "Lorem Ipsum", + Slug = "blog/lorem-ipsum", + Published = new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero), + Tags = ["asp-net", "azure"] + } + }; + + var page1 = new ContentDocument + { + Kind = ContentKind.Page, + Markdown = "# Getting Started", + Metadata = new ContentMetadata + { + Title = "Getting Started", + Slug = "getting-started", + Tags = ["blazor"] + } + }; + + var contentLoader = Substitute.For(); + contentLoader + .LoadAsync(Arg.Any()) + .Returns(Task.FromResult>(new[] { post1, post2, page1 })); + + var markdownService = Substitute.For(); + markdownService + .ToHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"HTML:{callInfo.ArgAt(0)}")); + + var pluginRunner = Substitute.For(); + pluginRunner.Manifests.Returns([]); + pluginRunner + .RunPreMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + + var themeService = Substitute.For(); + themeService + .LoadManifestAsync(Arg.Any()) + .Returns(Task.FromResult(new ThemeManifest { Name = "Minimal", Slug = "minimal" })); + themeService + .CopyAssetsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + IDictionary? capturedTagListParams = null; + + var renderer = Substitute.For(); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("INDEX")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("POST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("PAGE")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Do>(p => capturedTagListParams = p), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + + var logger = Substitute.For>(); + + var generator = new StaticSiteGenerator( + contentLoader, + markdownService, + pluginRunner, + themeService, + renderer, + paths, + fileSystem, + site, + logger); + + // Act + await generator.BuildAsync(destination, preview: false, CancellationToken.None); + + // Assert + capturedTagListParams.ShouldNotBeNull(); + capturedTagListParams!.ContainsKey("TaggedDocuments").ShouldBeTrue(); + + var taggedDocs = capturedTagListParams["TaggedDocuments"]!.ShouldBeAssignableTo Posts, IEnumerable Pages)>>(); + taggedDocs.ShouldNotBeNull(); + taggedDocs!.Count.ShouldBe(3); // blazor, azure, asp-net + + // Verify azure tag has posts sorted by date descending (lorem-ipsum first, then hello-world) + taggedDocs.ContainsKey("azure").ShouldBeTrue(); + var azurePosts = taggedDocs["azure"].Posts.ToList(); + azurePosts.Count.ShouldBe(2); + azurePosts[0].Metadata.Slug.ShouldBe("blog/lorem-ipsum"); // 2026-01-03 - newer + azurePosts[1].Metadata.Slug.ShouldBe("blog/hello-world"); // 2025-12-31 - older + + // Verify blazor tag has posts and pages + taggedDocs.ContainsKey("blazor").ShouldBeTrue(); + var blazorPosts = taggedDocs["blazor"].Posts.ToList(); + var blazorPages = taggedDocs["blazor"].Pages.ToList(); + blazorPosts.Count.ShouldBe(1); + blazorPages.Count.ShouldBe(1); + blazorPosts[0].Metadata.Slug.ShouldBe("blog/hello-world"); + blazorPages[0].Metadata.Slug.ShouldBe("getting-started"); } -} -internal sealed class TestPageView : ScissorHands.Theme.PageViewBase -{ - protected override void BuildRenderTree(RenderTreeBuilder builder) + [Fact] + public async Task Given_DocumentsWithMixedCaseTags_When_BuildAsync_Invoked_Then_It_Should_NormalizeTagsToLowercase() { - } -} + // Arrange + var fileSystem = new MockFileSystem(); + var root = fileSystem.Path.GetPathRoot(Environment.CurrentDirectory) ?? fileSystem.Path.DirectorySeparatorChar.ToString(); + var baseRoot = fileSystem.Path.Combine(root, "base"); + var contentsRoot = fileSystem.Path.Combine(baseRoot, "contents"); + var themesRoot = fileSystem.Path.Combine(baseRoot, "themes"); + var destination = fileSystem.Path.Combine(root, "out"); -internal sealed class TestNotFoundView : ScissorHands.Theme.NotFoundViewBase + var paths = new TestAppPaths(basePath: baseRoot, contentsRoot, themesRoot); + + var site = new SiteManifest + { + Title = "My Site", + Description = "My Description", + Theme = "minimal" + }; + + var post = new ContentDocument + { + Kind = ContentKind.Post, + Markdown = "# Post", + Metadata = new ContentMetadata + { + Title = "Post", + Slug = "blog/post", + Tags = ["Azure"] + } + }; + + var page = new ContentDocument + { + Kind = ContentKind.Page, + Markdown = "# Page", + Metadata = new ContentMetadata + { + Title = "Page", + Slug = "page", + Tags = ["azure"] + } + }; + + var contentLoader = Substitute.For(); + contentLoader + .LoadAsync(Arg.Any()) + .Returns(Task.FromResult>(new[] { post, page })); + + var markdownService = Substitute.For(); + markdownService + .ToHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"HTML:{callInfo.ArgAt(0)}")); + + var pluginRunner = Substitute.For(); + pluginRunner.Manifests.Returns([]); + pluginRunner + .RunPreMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"FINAL:{callInfo.ArgAt(0)}")); + + var themeService = Substitute.For(); + themeService + .LoadManifestAsync(Arg.Any()) + .Returns(Task.FromResult(new ThemeManifest { Name = "Minimal", Slug = "minimal" })); + themeService + .CopyAssetsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var capturedTagViewParams = new List>(); + + var renderer = Substitute.For(); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("INDEX")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("POST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("PAGE")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + renderer + .RenderAsync(Arg.Any(), Arg.Do>(p => capturedTagViewParams.Add(p)), Arg.Any()) + .Returns(Task.FromResult("TAG")); + + var logger = Substitute.For>(); + + var generator = new StaticSiteGenerator( + contentLoader, + markdownService, + pluginRunner, + themeService, + renderer, + paths, + fileSystem, + site, + logger); + + // Act + await generator.BuildAsync(destination, preview: false, CancellationToken.None); + + // Assert + fileSystem.File.Exists(fileSystem.Path.Combine(destination, "tags", "azure", "index.html")).ShouldBeTrue(); + + await pluginRunner + .Received() + .RunPostHtmlAsync(Arg.Any(), Arg.Is(d => d.Metadata.Slug == "tags/azure"), Arg.Any()); + + await pluginRunner + .DidNotReceive() + .RunPostHtmlAsync(Arg.Any(), Arg.Is(d => d.Metadata.Slug == "tags/Azure"), Arg.Any()); + + capturedTagViewParams.Count.ShouldBe(1); + capturedTagViewParams[0].ContainsKey("Tag").ShouldBeTrue(); + capturedTagViewParams[0]["Tag"].ShouldBe("azure"); + } + + [Fact] + public async Task Given_404DocumentWithTags_When_BuildAsync_Invoked_Then_It_Should_NotGenerateTagPageFor404() + { + // Arrange + var fileSystem = new MockFileSystem(); + var root = fileSystem.Path.GetPathRoot(Environment.CurrentDirectory) ?? fileSystem.Path.DirectorySeparatorChar.ToString(); + var baseRoot = fileSystem.Path.Combine(root, "base"); + var contentsRoot = fileSystem.Path.Combine(baseRoot, "contents"); + var themesRoot = fileSystem.Path.Combine(baseRoot, "themes"); + var destination = fileSystem.Path.Combine(root, "out"); + + var paths = new TestAppPaths(basePath: baseRoot, contentsRoot, themesRoot); + + var site = new SiteManifest + { + Title = "My Site", + Description = "My Description", + Theme = "minimal" + }; + + var notFoundPage = new ContentDocument + { + Kind = ContentKind.Page, + Markdown = "# 404", + Metadata = new ContentMetadata + { + Title = "404", + Slug = "404.html", + Tags = ["Hidden"] + } + }; + + var post = new ContentDocument + { + Kind = ContentKind.Post, + Markdown = "# Post", + Metadata = new ContentMetadata + { + Title = "Post", + Slug = "blog/post", + Tags = ["Visible"] + } + }; + + var contentLoader = Substitute.For(); + contentLoader + .LoadAsync(Arg.Any()) + .Returns(Task.FromResult>(new[] { notFoundPage, post })); + + var markdownService = Substitute.For(); + markdownService + .ToHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"HTML:{callInfo.ArgAt(0)}")); + + var pluginRunner = Substitute.For(); + pluginRunner.Manifests.Returns([]); + pluginRunner + .RunPreMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"FINAL:{callInfo.ArgAt(0)}")); + + var themeService = Substitute.For(); + themeService + .LoadManifestAsync(Arg.Any()) + .Returns(Task.FromResult(new ThemeManifest { Name = "Minimal", Slug = "minimal" })); + themeService + .CopyAssetsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + IDictionary? capturedTagListParams = null; + + var renderer = Substitute.For(); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("INDEX")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("POST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Do>(p => capturedTagListParams = p), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + + var logger = Substitute.For>(); + + var generator = new StaticSiteGenerator( + contentLoader, + markdownService, + pluginRunner, + themeService, + renderer, + paths, + fileSystem, + site, + logger); + + // Act + await generator.BuildAsync(destination, preview: false, CancellationToken.None); + + // Assert + fileSystem.File.Exists(fileSystem.Path.Combine(destination, "tags", "visible", "index.html")).ShouldBeTrue(); + fileSystem.File.Exists(fileSystem.Path.Combine(destination, "tags", "hidden", "index.html")).ShouldBeFalse(); + + capturedTagListParams.ShouldNotBeNull(); + var taggedDocs = capturedTagListParams!["TaggedDocuments"]!.ShouldBeAssignableTo Posts, IEnumerable Pages)>>(); + taggedDocs.ContainsKey("hidden").ShouldBeFalse(); + taggedDocs.ContainsKey("visible").ShouldBeTrue(); + } + + [Fact] + public async Task Given_MultipleTaggedPages_When_BuildAsync_Invoked_Then_TaggedPages_Should_BeSortedByTitleAscending() + { + // Arrange + var fileSystem = new MockFileSystem(); + var root = fileSystem.Path.GetPathRoot(Environment.CurrentDirectory) ?? fileSystem.Path.DirectorySeparatorChar.ToString(); + var baseRoot = fileSystem.Path.Combine(root, "base"); + var contentsRoot = fileSystem.Path.Combine(baseRoot, "contents"); + var themesRoot = fileSystem.Path.Combine(baseRoot, "themes"); + var destination = fileSystem.Path.Combine(root, "out"); + + var paths = new TestAppPaths(basePath: baseRoot, contentsRoot, themesRoot); + + var site = new SiteManifest + { + Title = "My Site", + Description = "My Description", + Theme = "minimal" + }; + + var pageZeta = new ContentDocument + { + Kind = ContentKind.Page, + Markdown = "# Zeta", + Metadata = new ContentMetadata + { + Title = "Zeta", + Slug = "zeta", + Tags = ["docs"] + } + }; + + var pageAlpha = new ContentDocument + { + Kind = ContentKind.Page, + Markdown = "# Alpha", + Metadata = new ContentMetadata + { + Title = "Alpha", + Slug = "alpha", + Tags = ["docs"] + } + }; + + var contentLoader = Substitute.For(); + contentLoader + .LoadAsync(Arg.Any()) + .Returns(Task.FromResult>(new[] { pageZeta, pageAlpha })); + + var markdownService = Substitute.For(); + markdownService + .ToHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"HTML:{callInfo.ArgAt(0)}")); + + var pluginRunner = Substitute.For(); + pluginRunner.Manifests.Returns([]); + pluginRunner + .RunPreMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + + var themeService = Substitute.For(); + themeService + .LoadManifestAsync(Arg.Any()) + .Returns(Task.FromResult(new ThemeManifest { Name = "Minimal", Slug = "minimal" })); + themeService + .CopyAssetsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + IDictionary? capturedTagListParams = null; + + var renderer = Substitute.For(); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("INDEX")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("PAGE")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Do>(p => capturedTagListParams = p), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + + var logger = Substitute.For>(); + + var generator = new StaticSiteGenerator( + contentLoader, + markdownService, + pluginRunner, + themeService, + renderer, + paths, + fileSystem, + site, + logger); + + // Act + await generator.BuildAsync(destination, preview: false, CancellationToken.None); + + // Assert + capturedTagListParams.ShouldNotBeNull(); + var taggedDocs = capturedTagListParams!["TaggedDocuments"]!.ShouldBeAssignableTo Posts, IEnumerable Pages)>>(); + + taggedDocs.ContainsKey("docs").ShouldBeTrue(); + var docsPages = taggedDocs["docs"].Pages.ToList(); + docsPages.Count.ShouldBe(2); + docsPages[0].Metadata.Title.ShouldBe("Alpha"); + docsPages[1].Metadata.Title.ShouldBe("Zeta"); + } + + [Fact] + public async Task Given_NoDocumentsWithTags_When_BuildAsync_Invoked_Then_It_Should_NotGenerateTagPages() + { + // Arrange + var fileSystem = new MockFileSystem(); + var root = fileSystem.Path.GetPathRoot(Environment.CurrentDirectory) ?? fileSystem.Path.DirectorySeparatorChar.ToString(); + var baseRoot = fileSystem.Path.Combine(root, "base"); + var contentsRoot = fileSystem.Path.Combine(baseRoot, "contents"); + var themesRoot = fileSystem.Path.Combine(baseRoot, "themes"); + var destination = fileSystem.Path.Combine(root, "out"); + + var paths = new TestAppPaths(basePath: baseRoot, contentsRoot, themesRoot); + + var site = new SiteManifest + { + Title = "My Site", + Description = "My Description", + Theme = "minimal" + }; + + var post = new ContentDocument + { + Kind = ContentKind.Post, + Markdown = "# Post without tags", + Metadata = new ContentMetadata + { + Title = "No Tags Post", + Slug = "blog/no-tags", + Tags = [] // Empty tags + } + }; + + var contentLoader = Substitute.For(); + contentLoader + .LoadAsync(Arg.Any()) + .Returns(Task.FromResult>(new[] { post })); + + var markdownService = Substitute.For(); + markdownService + .ToHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult($"HTML:{callInfo.ArgAt(0)}")); + + var pluginRunner = Substitute.For(); + pluginRunner.Manifests.Returns([]); + pluginRunner + .RunPreMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostMarkdownAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + pluginRunner + .RunPostHtmlAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0))); + + var themeService = Substitute.For(); + themeService + .LoadManifestAsync(Arg.Any()) + .Returns(Task.FromResult(new ThemeManifest { Name = "Minimal", Slug = "minimal" })); + themeService + .CopyAssetsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var renderer = Substitute.For(); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("INDEX")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("POST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("NOTFOUND")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAGLIST")); + renderer + .RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult("TAG")); + + var logger = Substitute.For>(); + + var generator = new StaticSiteGenerator( + contentLoader, + markdownService, + pluginRunner, + themeService, + renderer, + paths, + fileSystem, + site, + logger); + + // Act + await generator.BuildAsync(destination, preview: false, CancellationToken.None); + + // Assert - no tag pages should be generated + fileSystem.Directory.Exists(fileSystem.Path.Combine(destination, "tags")).ShouldBeFalse(); + + // Verify tag views were never rendered + await renderer.DidNotReceive().RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + await renderer.DidNotReceive().RenderAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } +} + +internal sealed class TestMainLayout : ScissorHands.Theme.MainLayoutBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } +} + +internal sealed class TestIndexView : ScissorHands.Theme.IndexViewBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } +} + +internal sealed class TestPostView : ScissorHands.Theme.PostViewBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } +} + +internal sealed class TestPageView : ScissorHands.Theme.PageViewBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } +} + +internal sealed class TestNotFoundView : ScissorHands.Theme.NotFoundViewBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } +} + +internal sealed class TestTagListView : ScissorHands.Theme.TagListViewBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + } +} + +internal sealed class TestTagView : ScissorHands.Theme.TagViewBase { protected override void BuildRenderTree(RenderTreeBuilder builder) {