+
+ @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
+
+
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)
+ {
+
+ }
+ }
+
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