From 0ab4c34687b3d42d8e368990c135332a83d02c34 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 2 Jan 2026 14:37:57 +1100 Subject: [PATCH 1/9] Update to cascading parameters --- src/ScissorHands.Theme/IndexViewBase.cs | 10 +++++----- src/ScissorHands.Theme/NotFoundViewBase.cs | 10 +++++----- src/ScissorHands.Theme/PageViewBase.cs | 10 +++++----- src/ScissorHands.Theme/PostViewBase.cs | 10 +++++----- .../Generators/StaticSiteGenerator.cs | 4 ++-- .../Renderers/ComponentRenderer.cs | 15 +++++++++++++++ .../themes/default/MainLayout.razor | 4 ++++ .../themes/default/_Imports.razor | 2 ++ .../IndexViewBaseTests.cs | 5 ++--- .../NotFoundViewBaseTests.cs | 4 ++-- .../ScissorHands.Theme.Tests/PageViewBaseTests.cs | 4 ++-- .../ScissorHands.Theme.Tests/PostViewBaseTests.cs | 4 ++-- 12 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/ScissorHands.Theme/IndexViewBase.cs b/src/ScissorHands.Theme/IndexViewBase.cs index 1e7a0f7..1b1790f 100644 --- a/src/ScissorHands.Theme/IndexViewBase.cs +++ b/src/ScissorHands.Theme/IndexViewBase.cs @@ -13,24 +13,24 @@ public abstract class IndexViewBase : ComponentBase /// /// Gets or sets the list of instances. /// - [Parameter] - public IEnumerable Documents { get; set; } = []; + [CascadingParameter] + public IEnumerable? Documents { get; set; } /// /// Gets or sets the list of instances. /// - [Parameter] + [CascadingParameter] public IEnumerable? Plugins { get; set; } /// /// Gets or sets the instance. /// - [Parameter] + [CascadingParameter] public ThemeManifest? Theme { get; set; } /// /// Gets or sets the instance. /// - [Parameter] + [CascadingParameter] public SiteManifest? Site { get; set; } } diff --git a/src/ScissorHands.Theme/NotFoundViewBase.cs b/src/ScissorHands.Theme/NotFoundViewBase.cs index 456cf5e..7641806 100644 --- a/src/ScissorHands.Theme/NotFoundViewBase.cs +++ b/src/ScissorHands.Theme/NotFoundViewBase.cs @@ -14,24 +14,24 @@ public abstract class NotFoundViewBase : ComponentBase /// Gets or sets the instance. /// If a page document with the slug 404.html exists, it will be provided here. /// - [Parameter] - public ContentDocument Document { get; set; } = new(); + [CascadingParameter] + public ContentDocument? Document { get; set; } /// /// Gets or sets the list of instances. /// - [Parameter] + [CascadingParameter] public IEnumerable? Plugins { get; set; } /// /// Gets or sets the instance. /// - [Parameter] + [CascadingParameter] public ThemeManifest? Theme { get; set; } /// /// Gets or sets the instance. /// - [Parameter] + [CascadingParameter] public SiteManifest? Site { get; set; } } diff --git a/src/ScissorHands.Theme/PageViewBase.cs b/src/ScissorHands.Theme/PageViewBase.cs index ce30d31..9cd3909 100644 --- a/src/ScissorHands.Theme/PageViewBase.cs +++ b/src/ScissorHands.Theme/PageViewBase.cs @@ -13,24 +13,24 @@ public abstract class PageViewBase : ComponentBase /// /// Gets or sets the instance. /// - [Parameter] - public ContentDocument Document { get; set; } = new(); + [CascadingParameter] + public ContentDocument? Document { get; set; } /// /// Gets or sets the list of instances. /// - [Parameter] + [CascadingParameter] public IEnumerable? Plugins { get; set; } /// /// Gets or sets the instance. /// - [Parameter] + [CascadingParameter] public ThemeManifest? Theme { get; set; } /// /// Gets or sets the instance. /// - [Parameter] + [CascadingParameter] public SiteManifest? Site { get; set; } } diff --git a/src/ScissorHands.Theme/PostViewBase.cs b/src/ScissorHands.Theme/PostViewBase.cs index c0aa16c..6b71633 100644 --- a/src/ScissorHands.Theme/PostViewBase.cs +++ b/src/ScissorHands.Theme/PostViewBase.cs @@ -13,24 +13,24 @@ public abstract class PostViewBase : ComponentBase /// /// Gets or sets the instance. /// - [Parameter] - public ContentDocument Document { get; set; } = new(); + [CascadingParameter] + public ContentDocument? Document { get; set; } /// /// Gets or sets the list of instances. /// - [Parameter] + [CascadingParameter] public IEnumerable? Plugins { get; set; } /// /// Gets or sets the instance. /// - [Parameter] + [CascadingParameter] public ThemeManifest? Theme { get; set; } /// /// Gets or sets the instance. /// - [Parameter] + [CascadingParameter] public SiteManifest? Site { get; set; } } diff --git a/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs b/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs index 6e2f803..e40df96 100644 --- a/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs +++ b/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs @@ -30,8 +30,8 @@ public sealed class StaticSiteGenerator( IPluginRunner pluginRunner, IThemeService themeService, IComponentRenderer renderer, - IAppPaths paths, - IFileSystem fileSystem, + IAppPaths paths, + IFileSystem fileSystem, SiteManifest options, ILogger logger) : IStaticSiteGenerator { diff --git a/src/ScissorHands.Web/Renderers/ComponentRenderer.cs b/src/ScissorHands.Web/Renderers/ComponentRenderer.cs index 3f09bca..9485785 100644 --- a/src/ScissorHands.Web/Renderers/ComponentRenderer.cs +++ b/src/ScissorHands.Web/Renderers/ComponentRenderer.cs @@ -31,8 +31,23 @@ public async Task RenderAsync(Type layoutType, IDictionary(0); var seq = 1; + + var cascadingKeys = new HashSet(StringComparer.Ordinal) + { + "Documents", + "Document", + "Plugins", + "Theme", + "Site" + }; + foreach (var kvp in parameters) { + if (cascadingKeys.Contains(kvp.Key)) + { + continue; + } + builder.AddAttribute(seq++, kvp.Key, kvp.Value); } builder.CloseComponent(); diff --git a/src/ScissorHands.Web/themes/default/MainLayout.razor b/src/ScissorHands.Web/themes/default/MainLayout.razor index 350e992..c1786c1 100644 --- a/src/ScissorHands.Web/themes/default/MainLayout.razor +++ b/src/ScissorHands.Web/themes/default/MainLayout.razor @@ -1,5 +1,7 @@ @inherits ScissorHands.Theme.MainLayoutBase + + @@ -32,3 +34,5 @@ } + + diff --git a/src/ScissorHands.Web/themes/default/_Imports.razor b/src/ScissorHands.Web/themes/default/_Imports.razor index 34e5615..910594a 100644 --- a/src/ScissorHands.Web/themes/default/_Imports.razor +++ b/src/ScissorHands.Web/themes/default/_Imports.razor @@ -1,3 +1,5 @@ +@using ScissorHands.Theme + @using Theme = ScissorHands.Core.Manifests.ThemeManifest @namespace ScissorHands.Web diff --git a/test/ScissorHands.Theme.Tests/IndexViewBaseTests.cs b/test/ScissorHands.Theme.Tests/IndexViewBaseTests.cs index fee4234..d53b3ed 100644 --- a/test/ScissorHands.Theme.Tests/IndexViewBaseTests.cs +++ b/test/ScissorHands.Theme.Tests/IndexViewBaseTests.cs @@ -3,7 +3,7 @@ namespace ScissorHands.Theme.Tests; public class IndexViewBaseTests { [Fact] - public void Given_IndexViewBase_When_Constructed_Then_It_Should_HaveNonNullDocuments() + public void Given_IndexViewBase_When_Constructed_Then_It_Should_HaveNullDocuments() { // Arrange @@ -11,8 +11,7 @@ public void Given_IndexViewBase_When_Constructed_Then_It_Should_HaveNonNullDocum var view = new TestIndexView(); // Assert - view.Documents.ShouldNotBeNull(); - view.Documents.ShouldBeEmpty(); + view.Documents.ShouldBeNull(); } } diff --git a/test/ScissorHands.Theme.Tests/NotFoundViewBaseTests.cs b/test/ScissorHands.Theme.Tests/NotFoundViewBaseTests.cs index a576fb1..875e407 100644 --- a/test/ScissorHands.Theme.Tests/NotFoundViewBaseTests.cs +++ b/test/ScissorHands.Theme.Tests/NotFoundViewBaseTests.cs @@ -3,7 +3,7 @@ namespace ScissorHands.Theme.Tests; public class NotFoundViewBaseTests { [Fact] - public void Given_NotFoundViewBase_When_Constructed_Then_It_Should_HaveNonNullDocument() + public void Given_NotFoundViewBase_When_Constructed_Then_It_Should_HaveNullDocument() { // Arrange @@ -11,7 +11,7 @@ public void Given_NotFoundViewBase_When_Constructed_Then_It_Should_HaveNonNullDo var view = new TestNotFoundView(); // Assert - view.Document.ShouldNotBeNull(); + view.Document.ShouldBeNull(); } } diff --git a/test/ScissorHands.Theme.Tests/PageViewBaseTests.cs b/test/ScissorHands.Theme.Tests/PageViewBaseTests.cs index 90e254b..6fbe913 100644 --- a/test/ScissorHands.Theme.Tests/PageViewBaseTests.cs +++ b/test/ScissorHands.Theme.Tests/PageViewBaseTests.cs @@ -3,7 +3,7 @@ namespace ScissorHands.Theme.Tests; public class PageViewBaseTests { [Fact] - public void Given_PageViewBase_When_Constructed_Then_It_Should_HaveNonNullDocument() + public void Given_PageViewBase_When_Constructed_Then_It_Should_HaveNullDocument() { // Arrange @@ -11,7 +11,7 @@ public void Given_PageViewBase_When_Constructed_Then_It_Should_HaveNonNullDocume var view = new TestPageView(); // Assert - view.Document.ShouldNotBeNull(); + view.Document.ShouldBeNull(); } } diff --git a/test/ScissorHands.Theme.Tests/PostViewBaseTests.cs b/test/ScissorHands.Theme.Tests/PostViewBaseTests.cs index e1f81ef..b74df98 100644 --- a/test/ScissorHands.Theme.Tests/PostViewBaseTests.cs +++ b/test/ScissorHands.Theme.Tests/PostViewBaseTests.cs @@ -3,7 +3,7 @@ namespace ScissorHands.Theme.Tests; public class PostViewBaseTests { [Fact] - public void Given_PostViewBase_When_Constructed_Then_It_Should_HaveNonNullDocument() + public void Given_PostViewBase_When_Constructed_Then_It_Should_HaveNullDocument() { // Arrange @@ -11,7 +11,7 @@ public void Given_PostViewBase_When_Constructed_Then_It_Should_HaveNonNullDocume var view = new TestPostView(); // Assert - view.Document.ShouldNotBeNull(); + view.Document.ShouldBeNull(); } } From 16f0ae31b787651231960b065f1992c0ab72f6a6 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 2 Jan 2026 15:10:47 +1100 Subject: [PATCH 2/9] Refactor StaticSiteGenerator --- .../Generators/StaticSiteGenerator.cs | 76 ++++++++++--------- .../themes/default/IndexView.razor | 4 +- .../themes/default/NotFoundView.razor | 2 +- .../themes/default/PageView.razor | 2 +- .../themes/default/PostView.razor | 2 +- 5 files changed, 47 insertions(+), 39 deletions(-) diff --git a/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs b/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs index e40df96..adc6e46 100644 --- a/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs +++ b/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs @@ -69,40 +69,10 @@ public async Task BuildAsync d.Kind == ContentKind.Page && string.Equals(d.Metadata.Slug, PAGE_NOT_FOUND_SLUG, StringComparison.OrdinalIgnoreCase)); await RenderNotFoundAsync(notFoundDocument, plugins, theme, destination, layoutType, cancellationToken); - foreach (var document in documents) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (document.Kind == ContentKind.Page && string.Equals(document.Metadata.Slug, PAGE_NOT_FOUND_SLUG, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - 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 parameters = new Dictionary - { - ["Document"] = postMarkdown, - ["Plugins"] = plugins, - ["Theme"] = theme, - ["Site"] = _options - }; - - 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); - } + var buildTasks = documents.Where(d => IsNotFoundPage(d) == false) + .Select(d => RenderDocumentAsync(d, plugins, theme, destination, layoutType, cancellationToken)) + .ToList(); + await Task.WhenAll(buildTasks); CopyContentAssets(destination); await _themeService.CopyAssetsAsync(_options.Theme, destination); @@ -190,6 +160,44 @@ private async Task RenderNotFoundAsync(ContentDocument? notFoundD _logger.LogInformation("Wrote {OutputPath}", outputPath); } + private async Task RenderDocumentAsync(ContentDocument document, IEnumerable plugins, ThemeManifest theme, string destination, Type layoutType, CancellationToken cancellationToken) + where TPostView : ScissorHands.Theme.PostViewBase + where TPageView : ScissorHands.Theme.PageViewBase + { + 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 parameters = new Dictionary + { + ["Document"] = postMarkdown, + ["Plugins"] = plugins, + ["Theme"] = theme, + ["Site"] = _options + }; + + 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); + } + + 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)) diff --git a/src/ScissorHands.Web/themes/default/IndexView.razor b/src/ScissorHands.Web/themes/default/IndexView.razor index 4d7e838..a7a441e 100644 --- a/src/ScissorHands.Web/themes/default/IndexView.razor +++ b/src/ScissorHands.Web/themes/default/IndexView.razor @@ -3,11 +3,11 @@
- @if (Documents.Any() == false) + @if (Documents?.Any() == false) {

Welcome aboard ScissorHands.NET!

} - @foreach (var post in Documents) + @foreach (var post in Documents!) {
diff --git a/src/ScissorHands.Web/themes/default/NotFoundView.razor b/src/ScissorHands.Web/themes/default/NotFoundView.razor index 43c4d33..605cd1a 100644 --- a/src/ScissorHands.Web/themes/default/NotFoundView.razor +++ b/src/ScissorHands.Web/themes/default/NotFoundView.razor @@ -2,7 +2,7 @@ @layout MainLayout
- @if (string.IsNullOrWhiteSpace(Document.Html)) + @if (string.IsNullOrWhiteSpace(Document?.Html) == true) {

404 - Not Found

diff --git a/src/ScissorHands.Web/themes/default/PageView.razor b/src/ScissorHands.Web/themes/default/PageView.razor index dc7dcf8..cad9ab5 100644 --- a/src/ScissorHands.Web/themes/default/PageView.razor +++ b/src/ScissorHands.Web/themes/default/PageView.razor @@ -3,6 +3,6 @@
- @((MarkupString)Document.Html) + @((MarkupString)Document?.Html!)
diff --git a/src/ScissorHands.Web/themes/default/PostView.razor b/src/ScissorHands.Web/themes/default/PostView.razor index c307c49..792c95f 100644 --- a/src/ScissorHands.Web/themes/default/PostView.razor +++ b/src/ScissorHands.Web/themes/default/PostView.razor @@ -3,6 +3,6 @@
- @((MarkupString)Document.Html) + @((MarkupString)Document?.Html!)
From 39eb17609ce41550d6d0f2b8c07977cbdb214f94 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 2 Jan 2026 16:10:29 +1100 Subject: [PATCH 3/9] Refactor ScissorHandsApplication to builder --- .../ScissorHandsApplication.cs | 108 +++++++-------- .../ScissorHandsApplicationBuilder.cs | 124 ++++++++++++++++++ 2 files changed, 173 insertions(+), 59 deletions(-) create mode 100644 src/ScissorHands.Web/ScissorHandsApplicationBuilder.cs diff --git a/src/ScissorHands.Web/ScissorHandsApplication.cs b/src/ScissorHands.Web/ScissorHandsApplication.cs index 1f9d1e8..9a53d52 100644 --- a/src/ScissorHands.Web/ScissorHandsApplication.cs +++ b/src/ScissorHands.Web/ScissorHandsApplication.cs @@ -7,9 +7,8 @@ using ScissorHands.Core.Manifests; using ScissorHands.Core.Options; -using ScissorHands.Web.Application; using ScissorHands.Web.Abstractions; -using ScissorHands.Web.Extensions; +using ScissorHands.Web.Application; using ScissorHands.Web.Generators; namespace ScissorHands.Web; @@ -19,18 +18,6 @@ namespace ScissorHands.Web; /// public interface IScissorHandsApplication { - /// - /// Verifies the command arguments. - /// - /// Returns the instance. - IScissorHandsApplication VerifyCommandArguments(); - - /// - /// Builds the application. - /// - /// Returns the instance. - Task BuildAsync(); - /// /// Runs the application. /// @@ -40,29 +27,34 @@ public interface IScissorHandsApplication /// /// This represents the ScissorHands application entity. /// -/// Type of main layout component. -/// Type of index view component. -/// Type of post view component. -/// Type of page view component. -/// Type of not found (404) view component. -public class ScissorHandsApplication(params IEnumerable args) : IScissorHandsApplication - 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 +public sealed class ScissorHandsApplication : IScissorHandsApplication { private const string APP_LOGGER_NAME = "App"; - private readonly IEnumerable _args = [.. args]; + private readonly string[] _args; + private readonly Type _mainLayout; + private readonly Type _indexView; + private readonly Type _postView; + private readonly Type _pageView; + private readonly Type _notFoundView; private CommandMode _mode; - private WebApplication? _app; + private readonly WebApplication _app; private ILogger? _logger; private SiteManifest? _site; private IStaticSiteGenerator? _generator; - /// - public IScissorHandsApplication VerifyCommandArguments() + internal ScissorHandsApplication(WebApplication app, IEnumerable args, Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView) + { + _app = app ?? throw new ArgumentNullException(nameof(app)); + _args = args?.ToArray() ?? throw new ArgumentNullException(nameof(args)); + _mainLayout = mainLayout ?? throw new ArgumentNullException(nameof(mainLayout)); + _indexView = indexView ?? throw new ArgumentNullException(nameof(indexView)); + _postView = postView ?? throw new ArgumentNullException(nameof(postView)); + _pageView = pageView ?? throw new ArgumentNullException(nameof(pageView)); + _notFoundView = notFoundView ?? throw new ArgumentNullException(nameof(notFoundView)); + } + + private void VerifyCommandArguments() { DisplayBanner(); @@ -77,7 +69,7 @@ public IScissorHandsApplication VerifyCommandArguments() { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine(); - Console.WriteLine("ERROR: No parameter provided."); + Console.WriteLine("ERROR: No parameter or invalid parameter provided."); Console.ResetColor(); DisplayHelp(); @@ -85,45 +77,28 @@ public IScissorHandsApplication VerifyCommandArguments() } _mode = validation.Mode; - - return this; - } - - /// - public async Task BuildAsync() - { - var builder = WebApplication.CreateBuilder([.. _args]); - - var config = builder.Configuration; - builder.Services.AddConfigurations(config) - .AddServices(config) - .AddRazorComponents(); - - _app = builder.Build(); - - return this; } /// public async Task RunAsync() { - if (_mode is not CommandMode.Preview) - { - return; - } + VerifyCommandArguments(); - _logger = _app!.Services.GetRequiredService().CreateLogger(APP_LOGGER_NAME); - _site = _app!.Services.GetRequiredService(); - _generator = _app!.Services.GetRequiredService(); + _logger = _app.Services.GetRequiredService().CreateLogger(APP_LOGGER_NAME); + _site = _app.Services.GetRequiredService(); + _generator = _app.Services.GetRequiredService(); - var result = _mode switch + _ = _mode switch { CommandMode.Preview => await RunPreviewServerAsync(), CommandMode.Build => await RunBuildAsync(), _ => LogInvalidMode() }; - await _app!.RunAsync(); + if (_mode == CommandMode.Preview) + { + await _app.RunAsync(); + } } private static void DisplayHelp() @@ -185,7 +160,7 @@ private async Task RunPreviewServerAsync() Directory.Delete(previewPath, recursive: true); } - await _generator!.BuildAsync(previewPath, preview: true, _app!.Lifetime.ApplicationStopping); + await BuildSiteAsync(previewPath, preview: true, _app.Lifetime.ApplicationStopping); var fileProvider = new PhysicalFileProvider(previewPath); _app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = fileProvider }); @@ -211,7 +186,7 @@ private async Task RunPreviewServerAsync() async () => { _logger!.LogInformation("Change detected; rebuilding preview..."); - await _generator!.BuildAsync(previewPath, preview: true, CancellationToken.None); + await BuildSiteAsync(previewPath, preview: true, CancellationToken.None); _logger!.LogInformation("Preview rebuilt. Refresh your browser to see the changes."); }); @@ -228,12 +203,27 @@ private async Task RunBuildAsync() Directory.Delete(outputPath, recursive: true); } - await _generator!.BuildAsync(outputPath, preview: false, CancellationToken.None); + await BuildSiteAsync(outputPath, preview: false, CancellationToken.None); _logger!.LogInformation("Build complete. Output at {OutputPath}", outputPath); return this; } + private Task BuildSiteAsync(string destination, bool preview, CancellationToken cancellationToken) + { + var buildMethod = _generator!.GetType() + .GetMethods() + .Single(m => string.Equals(m.Name, nameof(IStaticSiteGenerator.BuildAsync), StringComparison.Ordinal) && + m.IsGenericMethodDefinition && + m.GetGenericArguments().Length == 5 && + m.GetParameters().Length == 3); + + var closedMethod = buildMethod.MakeGenericMethod(_mainLayout, _indexView, _postView, _pageView, _notFoundView); + var task = (Task?)closedMethod.Invoke(_generator, [ destination, preview, cancellationToken ]); + + return task ?? Task.CompletedTask; + } + private IScissorHandsApplication LogInvalidMode() { _logger!.LogError("Invalid command mode: {Mode}", _mode); diff --git a/src/ScissorHands.Web/ScissorHandsApplicationBuilder.cs b/src/ScissorHands.Web/ScissorHandsApplicationBuilder.cs new file mode 100644 index 0000000..e83ad03 --- /dev/null +++ b/src/ScissorHands.Web/ScissorHandsApplicationBuilder.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +using ScissorHands.Theme; +using ScissorHands.Web.Extensions; + +namespace ScissorHands.Web; + +/// +/// This provides the interface for ScissorHands application builder. +/// +public interface IScissorHandsApplicationBuilder +{ + /// + /// Add layouts to the application. + /// + /// Type of the main layout. + /// Type of the index view. + /// Type of the post view. + /// Type of the page view. + /// Type of the not found view. + /// Returns instance. + IScissorHandsApplicationBuilder AddLayouts() + where TMainLayout : MainLayoutBase + where TIndexView : IndexViewBase + where TPostView : PostViewBase + where TPageView : PageViewBase + where TNotFoundView : NotFoundViewBase; + + /// + /// Add layouts to the application. + /// + /// Type of the main layout. + /// Type of the index view. + /// Type of the post view. + /// Type of the page view. + /// Type of the not found view. + /// Returns instance. + IScissorHandsApplicationBuilder AddLayouts(Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView); + + /// + /// Builds the application. + /// + /// Returns instance. + IScissorHandsApplication Build(); +} + +/// +/// This represents the builder entity for . +/// +public sealed class ScissorHandsApplicationBuilder(IEnumerable? args = null) : IScissorHandsApplicationBuilder +{ + private readonly string[] _args = (string[])(args ?? []); + + private Type? _mainLayout; + private Type? _indexView; + private Type? _postView; + private Type? _pageView; + private Type? _notFoundView; + + /// + public IScissorHandsApplicationBuilder AddLayouts() + where TMainLayout : MainLayoutBase + where TIndexView : IndexViewBase + where TPostView : PostViewBase + where TPageView : PageViewBase + where TNotFoundView : NotFoundViewBase + { + return AddLayouts(typeof(TMainLayout), typeof(TIndexView), typeof(TPostView), typeof(TPageView), typeof(TNotFoundView)); + } + + /// + public IScissorHandsApplicationBuilder AddLayouts(Type mainLayout, Type indexView, Type postView, Type pageView, Type notFoundView) + { + ArgumentNullException.ThrowIfNull(mainLayout); + ArgumentNullException.ThrowIfNull(indexView); + ArgumentNullException.ThrowIfNull(postView); + ArgumentNullException.ThrowIfNull(pageView); + ArgumentNullException.ThrowIfNull(notFoundView); + + EnsureAssignableTo(mainLayout, nameof(mainLayout)); + EnsureAssignableTo(indexView, nameof(indexView)); + EnsureAssignableTo(postView, nameof(postView)); + EnsureAssignableTo(pageView, nameof(pageView)); + EnsureAssignableTo(notFoundView, nameof(notFoundView)); + + _mainLayout = mainLayout; + _indexView = indexView; + _postView = postView; + _pageView = pageView; + _notFoundView = notFoundView; + + return this; + } + + /// + public IScissorHandsApplication Build() + { + if (_mainLayout is null || _indexView is null || _postView is null || _pageView is null || _notFoundView is null) + { + throw new InvalidOperationException("Layouts are not configured. Call AddLayouts(...) before Build()."); + } + + var builder = WebApplication.CreateBuilder(_args); + + var config = builder.Configuration; + builder.Services + .AddConfigurations(config) + .AddServices(config) + .AddRazorComponents(); + + var app = builder.Build(); + + return new ScissorHandsApplication(app, _args, _mainLayout, _indexView, _postView, _pageView, _notFoundView); + } + + private static void EnsureAssignableTo(Type type, string paramName) + { + if (typeof(TBase).IsAssignableFrom(type) == false) + { + throw new ArgumentException($"Type '{type.FullName}' must derive from '{typeof(TBase).FullName}'.", paramName); + } + } +} From 8e2f6bf6038671513d8df174412dfc2f3cb976cd Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 2 Jan 2026 16:18:35 +1100 Subject: [PATCH 4/9] Add more tests --- .../IndexViewBaseTests.cs | 31 ++++++ .../NotFoundViewBaseTests.cs | 34 +++++++ .../PageViewBaseTests.cs | 34 +++++++ .../PostViewBaseTests.cs | 34 +++++++ ...mponentRendererCascadingParametersTests.cs | 99 +++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 test/ScissorHands.Web.Tests/Renderers/ComponentRendererCascadingParametersTests.cs diff --git a/test/ScissorHands.Theme.Tests/IndexViewBaseTests.cs b/test/ScissorHands.Theme.Tests/IndexViewBaseTests.cs index d53b3ed..6b3b6a2 100644 --- a/test/ScissorHands.Theme.Tests/IndexViewBaseTests.cs +++ b/test/ScissorHands.Theme.Tests/IndexViewBaseTests.cs @@ -1,3 +1,6 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + namespace ScissorHands.Theme.Tests; public class IndexViewBaseTests @@ -13,6 +16,34 @@ public void Given_IndexViewBase_When_Constructed_Then_It_Should_HaveNullDocument // Assert view.Documents.ShouldBeNull(); } + + [Fact] + public void Given_IndexViewBase_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(); + + // Act + var cut = context.Renderer.Render(p => p + .Add(x => x.Documents, documents) + .Add(x => x.Plugins, plugins) + .Add(x => x.Theme, theme) + .Add(x => x.Site, site) + .AddChildContent()); + + var view = cut.FindComponent().Instance; + + // Assert + view.Documents.ShouldBeSameAs(documents); + view.Plugins.ShouldBeSameAs(plugins); + view.Theme.ShouldBeSameAs(theme); + view.Site.ShouldBeSameAs(site); + } } internal class TestIndexView : IndexViewBase diff --git a/test/ScissorHands.Theme.Tests/NotFoundViewBaseTests.cs b/test/ScissorHands.Theme.Tests/NotFoundViewBaseTests.cs index 875e407..b4b2ee7 100644 --- a/test/ScissorHands.Theme.Tests/NotFoundViewBaseTests.cs +++ b/test/ScissorHands.Theme.Tests/NotFoundViewBaseTests.cs @@ -1,3 +1,6 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + namespace ScissorHands.Theme.Tests; public class NotFoundViewBaseTests @@ -13,6 +16,37 @@ public void Given_NotFoundViewBase_When_Constructed_Then_It_Should_HaveNullDocum // Assert view.Document.ShouldBeNull(); } + + [Fact] + public void Given_NotFoundViewBase_When_RenderedWithCascadingValues_Then_It_Should_BindCascadingParameters() + { + // Arrange + using var context = new BunitContext(); + + var document = new ContentDocument + { + Metadata = new ContentMetadata { Title = "Not Found", Slug = "404.html" } + }; + var plugins = new List { new() }; + var theme = new ThemeManifest { Name = "test", Slug = "test" }; + var site = new SiteManifest(); + + // Act + var cut = context.Renderer.Render(p => p + .Add(x => x.Document, document) + .Add(x => x.Plugins, plugins) + .Add(x => x.Theme, theme) + .Add(x => x.Site, site) + .AddChildContent()); + + var view = cut.FindComponent().Instance; + + // Assert + view.Document.ShouldBeSameAs(document); + view.Plugins.ShouldBeSameAs(plugins); + view.Theme.ShouldBeSameAs(theme); + view.Site.ShouldBeSameAs(site); + } } internal class TestNotFoundView : NotFoundViewBase diff --git a/test/ScissorHands.Theme.Tests/PageViewBaseTests.cs b/test/ScissorHands.Theme.Tests/PageViewBaseTests.cs index 6fbe913..4f10bf2 100644 --- a/test/ScissorHands.Theme.Tests/PageViewBaseTests.cs +++ b/test/ScissorHands.Theme.Tests/PageViewBaseTests.cs @@ -1,3 +1,6 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + namespace ScissorHands.Theme.Tests; public class PageViewBaseTests @@ -13,6 +16,37 @@ public void Given_PageViewBase_When_Constructed_Then_It_Should_HaveNullDocument( // Assert view.Document.ShouldBeNull(); } + + [Fact] + public void Given_PageViewBase_When_RenderedWithCascadingValues_Then_It_Should_BindCascadingParameters() + { + // Arrange + using var context = new BunitContext(); + + var document = new ContentDocument + { + Metadata = new ContentMetadata { Title = "Hello", Slug = "hello" } + }; + var plugins = new List { new() }; + var theme = new ThemeManifest { Name = "test", Slug = "test" }; + var site = new SiteManifest(); + + // Act + var cut = context.Renderer.Render(p => p + .Add(x => x.Document, document) + .Add(x => x.Plugins, plugins) + .Add(x => x.Theme, theme) + .Add(x => x.Site, site) + .AddChildContent()); + + var view = cut.FindComponent().Instance; + + // Assert + view.Document.ShouldBeSameAs(document); + view.Plugins.ShouldBeSameAs(plugins); + view.Theme.ShouldBeSameAs(theme); + view.Site.ShouldBeSameAs(site); + } } internal class TestPageView : PageViewBase diff --git a/test/ScissorHands.Theme.Tests/PostViewBaseTests.cs b/test/ScissorHands.Theme.Tests/PostViewBaseTests.cs index b74df98..6f3add9 100644 --- a/test/ScissorHands.Theme.Tests/PostViewBaseTests.cs +++ b/test/ScissorHands.Theme.Tests/PostViewBaseTests.cs @@ -1,3 +1,6 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + namespace ScissorHands.Theme.Tests; public class PostViewBaseTests @@ -13,6 +16,37 @@ public void Given_PostViewBase_When_Constructed_Then_It_Should_HaveNullDocument( // Assert view.Document.ShouldBeNull(); } + + [Fact] + public void Given_PostViewBase_When_RenderedWithCascadingValues_Then_It_Should_BindCascadingParameters() + { + // Arrange + using var context = new BunitContext(); + + var document = new ContentDocument + { + Metadata = new ContentMetadata { Title = "Hello", Slug = "hello" } + }; + var plugins = new List { new() }; + var theme = new ThemeManifest { Name = "test", Slug = "test" }; + var site = new SiteManifest(); + + // Act + var cut = context.Renderer.Render(p => p + .Add(x => x.Document, document) + .Add(x => x.Plugins, plugins) + .Add(x => x.Theme, theme) + .Add(x => x.Site, site) + .AddChildContent()); + + var view = cut.FindComponent().Instance; + + // Assert + view.Document.ShouldBeSameAs(document); + view.Plugins.ShouldBeSameAs(plugins); + view.Theme.ShouldBeSameAs(theme); + view.Site.ShouldBeSameAs(site); + } } internal class TestPostView : PostViewBase diff --git a/test/ScissorHands.Web.Tests/Renderers/ComponentRendererCascadingParametersTests.cs b/test/ScissorHands.Web.Tests/Renderers/ComponentRendererCascadingParametersTests.cs new file mode 100644 index 0000000..eb12186 --- /dev/null +++ b/test/ScissorHands.Web.Tests/Renderers/ComponentRendererCascadingParametersTests.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; +using ScissorHands.Theme; +using ScissorHands.Web.Renderers; + +namespace ScissorHands.Web.Tests.Renderers; + +public class ComponentRendererCascadingParametersTests +{ + [Fact] + public async Task Given_ComponentRenderer_When_RenderingWithLayout_Then_It_Should_PassCascadingValuesViaLayout_NotAsComponentAttributes() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + using var provider = services.BuildServiceProvider(); + + var scopeFactory = provider.GetRequiredService(); + var loggerFactory = provider.GetRequiredService(); + + var renderer = new ComponentRenderer(scopeFactory, loggerFactory); + + var document = new ContentDocument + { + Metadata = new ContentMetadata { Title = "Hello", Slug = "hello" } + }; + var plugins = new List { new() }; + var theme = new ThemeManifest { Name = "test", Slug = "test" }; + var site = new SiteManifest(); + + var parameters = new Dictionary + { + ["Document"] = document, + ["Plugins"] = plugins, + ["Theme"] = theme, + ["Site"] = site, + ["Extra"] = "extra" + }; + + // Act + var html = await renderer.RenderAsync(typeof(TestCascadingLayout), parameters); + + // Assert + html.ShouldContain("extra"); + html.ShouldContain("Hello"); + } + + private sealed class TestCascadingLayout : LayoutComponentBase + { + [Parameter] + public IEnumerable? Documents { get; set; } + + [Parameter] + public ContentDocument? Document { get; set; } + + [Parameter] + public IEnumerable? Plugins { get; set; } + + [Parameter] + public ThemeManifest? Theme { get; set; } + + [Parameter] + public SiteManifest? Site { get; set; } + + [Parameter] + public string? Extra { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(CascadingMainLayoutBase.Documents), Documents); + builder.AddAttribute(2, nameof(CascadingMainLayoutBase.Document), Document); + builder.AddAttribute(3, nameof(CascadingMainLayoutBase.Plugins), Plugins); + builder.AddAttribute(4, nameof(CascadingMainLayoutBase.Theme), Theme); + builder.AddAttribute(5, nameof(CascadingMainLayoutBase.Site), Site); + builder.AddAttribute(6, nameof(CascadingMainLayoutBase.ChildContent), (RenderFragment)(b => b.AddContent(0, Body))); + builder.CloseComponent(); + } + } + + private sealed class TestCascadingPageView : PageViewBase + { + [Parameter] + public string? Extra { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "div"); + builder.AddContent(1, $"{Extra}|{Document?.Metadata.Title}"); + builder.CloseElement(); + } + } +} From 8aa213094ab3f121b3985d82812a5a93c3ee02e2 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 2 Jan 2026 16:22:34 +1100 Subject: [PATCH 5/9] Update README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index afae38c..25e7dff 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ A Blazor-based static site generator ```csharp using ScissorHands.Web; - var app = await new ScissorHandsApplication(args) - .VerifyCommandArguments() - .BuildAsync(); + var app = new ScissorHandsApplicationBuilder(args) + .AddLayouts() + .Build(); await app.RunAsync(); ``` From fd4ac8ef0ba6a96c1182fd822a4f7cd371b3b302 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Fri, 2 Jan 2026 16:33:39 +1100 Subject: [PATCH 6/9] Update src/ScissorHands.Web/Generators/StaticSiteGenerator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/ScissorHands.Web/Generators/StaticSiteGenerator.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs b/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs index adc6e46..7f511de 100644 --- a/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs +++ b/src/ScissorHands.Web/Generators/StaticSiteGenerator.cs @@ -69,10 +69,10 @@ public async Task BuildAsync d.Kind == ContentKind.Page && string.Equals(d.Metadata.Slug, PAGE_NOT_FOUND_SLUG, StringComparison.OrdinalIgnoreCase)); await RenderNotFoundAsync(notFoundDocument, plugins, theme, destination, layoutType, cancellationToken); - var buildTasks = documents.Where(d => IsNotFoundPage(d) == false) - .Select(d => RenderDocumentAsync(d, plugins, theme, destination, layoutType, cancellationToken)) - .ToList(); - await Task.WhenAll(buildTasks); + foreach (var document in documents.Where(d => IsNotFoundPage(d) == false)) + { + await RenderDocumentAsync(document, plugins, theme, destination, layoutType, cancellationToken); + } CopyContentAssets(destination); await _themeService.CopyAssetsAsync(_options.Theme, destination); From 127f384ab509476123ff6f3edbf61a9376094911 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:52:49 +1100 Subject: [PATCH 7/9] Fix null-checking logic in IndexView.razor (#76) * Initial plan * Fix null-checking logic in IndexView.razor Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> --- src/ScissorHands.Web/themes/default/IndexView.razor | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ScissorHands.Web/themes/default/IndexView.razor b/src/ScissorHands.Web/themes/default/IndexView.razor index a7a441e..32fb6d3 100644 --- a/src/ScissorHands.Web/themes/default/IndexView.razor +++ b/src/ScissorHands.Web/themes/default/IndexView.razor @@ -3,11 +3,13 @@
- @if (Documents?.Any() == false) + @if (Documents?.Any() != true) {

Welcome aboard ScissorHands.NET!

} - @foreach (var post in Documents!) + else + { + @foreach (var post in Documents!) {
@@ -20,6 +22,7 @@
}
+ } }
From df7b1eb031034441b4fe54a9bebfb1ff5271090e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:57:11 +1100 Subject: [PATCH 8/9] Use reflection to discover cascading parameters in ComponentRenderer (#75) * Initial plan * Refactor ComponentRenderer to use reflection for cascading parameter detection Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> * Improve reflection to auto-discover cascading parameters from entire Theme assembly Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> * Add error handling and use GetExportedTypes for safer reflection Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> --- .../Renderers/ComponentRenderer.cs | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/src/ScissorHands.Web/Renderers/ComponentRenderer.cs b/src/ScissorHands.Web/Renderers/ComponentRenderer.cs index 9485785..d129188 100644 --- a/src/ScissorHands.Web/Renderers/ComponentRenderer.cs +++ b/src/ScissorHands.Web/Renderers/ComponentRenderer.cs @@ -1,8 +1,12 @@ +using System.Reflection; + using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ScissorHands.Theme; + namespace ScissorHands.Web.Renderers; /// @@ -15,6 +19,39 @@ public sealed class ComponentRenderer(IServiceScopeFactory scopeFactory, ILogger private readonly IServiceScopeFactory _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); private readonly ILoggerFactory _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + // Lazy initialization of cascading parameter names discovered via reflection + private static readonly Lazy> _cascadingParameterNames = new(() => + { + var parameterNames = new HashSet(StringComparer.Ordinal); + + // Discover all types in the ScissorHands.Theme assembly that have cascading parameters + var themeAssembly = typeof(PageViewBase).Assembly; + + try + { + var allTypes = themeAssembly.GetExportedTypes(); + + foreach (var type in allTypes) + { + // Find all properties with CascadingParameter attribute + var cascadingProperties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(p => p.GetCustomAttribute() is not null); + + foreach (var property in cascadingProperties) + { + parameterNames.Add(property.Name); + } + } + } + catch (ReflectionTypeLoadException) + { + // If type loading fails, fall back to empty set + // This should not happen in normal operation, but provides safety + } + + return parameterNames; + }); + /// public async Task RenderAsync(Type layoutType, IDictionary parameters, CancellationToken cancellationToken = default) where TComponent : IComponent @@ -32,18 +69,9 @@ public async Task RenderAsync(Type layoutType, IDictionary(0); var seq = 1; - var cascadingKeys = new HashSet(StringComparer.Ordinal) - { - "Documents", - "Document", - "Plugins", - "Theme", - "Site" - }; - foreach (var kvp in parameters) { - if (cascadingKeys.Contains(kvp.Key)) + if (_cascadingParameterNames.Value.Contains(kvp.Key)) { continue; } From 225d0858d167a26592b6cdb21cda64c38665f457 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Jan 2026 17:03:20 +1100 Subject: [PATCH 9/9] Improve reflection-based method invocation with caching and error handling (#77) * Initial plan * Add robust error handling and caching for reflection-based method invocation Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> * Add thread-safety and improve exception handling Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> * Address final code review feedback - constants and stack trace preservation Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: justinyoo <1538528+justinyoo@users.noreply.github.com> --- .../ScissorHandsApplication.cs | 91 +++++++++++++++++-- 1 file changed, 82 insertions(+), 9 deletions(-) diff --git a/src/ScissorHands.Web/ScissorHandsApplication.cs b/src/ScissorHands.Web/ScissorHandsApplication.cs index 9a53d52..9ef3cfa 100644 --- a/src/ScissorHands.Web/ScissorHandsApplication.cs +++ b/src/ScissorHands.Web/ScissorHandsApplication.cs @@ -1,3 +1,6 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; + using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; @@ -30,6 +33,8 @@ 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_METHOD_PARAMETER_COUNT = 3; private readonly string[] _args; private readonly Type _mainLayout; @@ -42,6 +47,8 @@ public sealed class ScissorHandsApplication : IScissorHandsApplication private ILogger? _logger; private SiteManifest? _site; private IStaticSiteGenerator? _generator; + 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) { @@ -211,17 +218,83 @@ private async Task RunBuildAsync() private Task BuildSiteAsync(string destination, bool preview, CancellationToken cancellationToken) { - var buildMethod = _generator!.GetType() - .GetMethods() - .Single(m => string.Equals(m.Name, nameof(IStaticSiteGenerator.BuildAsync), StringComparison.Ordinal) && - m.IsGenericMethodDefinition && - m.GetGenericArguments().Length == 5 && - m.GetParameters().Length == 3); + try + { + // Use cached method info if available, otherwise find and cache it + if (_cachedBuildMethod == null) + { + lock (_cacheLock) + { + if (_cachedBuildMethod == null) + { + _cachedBuildMethod = GetBuildAsyncMethod(); + } + } + } + + var closedMethod = _cachedBuildMethod.MakeGenericMethod(_mainLayout, _indexView, _postView, _pageView, _notFoundView); + var parameters = new object[] { destination, preview, cancellationToken }; + var task = (Task?)closedMethod.Invoke(_generator, parameters); + + return task ?? Task.CompletedTask; + } + catch (InvalidOperationException ex) + { + _logger?.LogError(ex, "Failed to invoke BuildAsync method. The method signature may have changed or multiple overloads exist."); + throw new InvalidOperationException("Failed to invoke the BuildAsync method on the static site generator. Please ensure the IStaticSiteGenerator interface has not changed.", ex); + } + catch (TargetInvocationException ex) + { + _logger?.LogError(ex.InnerException ?? ex, "BuildAsync method threw an exception during execution."); + + // Re-throw the inner exception while preserving the stack trace + if (ex.InnerException != null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + } + throw; + } + catch (ArgumentException ex) + { + _logger?.LogError(ex, "Failed to create generic method with the provided type arguments."); + throw new InvalidOperationException("Failed to create the generic BuildAsync method. The provided view types may not satisfy the required constraints.", ex); + } + } + + private MethodInfo GetBuildAsyncMethod() + { + if (_generator == null) + { + throw new InvalidOperationException("Static site generator has not been initialized."); + } + + var generatorType = _generator.GetType(); + var methods = generatorType.GetMethods() + .Where(m => string.Equals(m.Name, nameof(IStaticSiteGenerator.BuildAsync), StringComparison.Ordinal) && + m.IsGenericMethodDefinition) + .ToList(); + + if (methods.Count == 0) + { + throw new InvalidOperationException($"No BuildAsync method found on type {generatorType.Name}. Ensure the generator implements IStaticSiteGenerator."); + } - var closedMethod = buildMethod.MakeGenericMethod(_mainLayout, _indexView, _postView, _pageView, _notFoundView); - var task = (Task?)closedMethod.Invoke(_generator, [ destination, preview, cancellationToken ]); + // Filter to the expected signature: 5 generic parameters, 3 method parameters + var matchingMethods = methods.Where(m => m.GetGenericArguments().Length == EXPECTED_GENERIC_PARAMETER_COUNT && + m.GetParameters().Length == EXPECTED_METHOD_PARAMETER_COUNT) + .ToList(); + + if (matchingMethods.Count == 0) + { + throw new InvalidOperationException($"No BuildAsync method with the expected signature ({EXPECTED_GENERIC_PARAMETER_COUNT} generic parameters, {EXPECTED_METHOD_PARAMETER_COUNT} method parameters) found on type {generatorType.Name}."); + } + + if (matchingMethods.Count > 1) + { + throw new InvalidOperationException($"Multiple BuildAsync methods with the expected signature found on type {generatorType.Name}. Cannot determine which method to invoke."); + } - return task ?? Task.CompletedTask; + return matchingMethods[0]; } private IScissorHandsApplication LogInvalidMode()