statiqdev / Statiq.Framework

A flexible and extensible static content generation framework for .NET.
https://statiq.dev/framework
MIT License
425 stars 74 forks source link

RenderRazor().WithModel raise error with strongly-typed ViewModel #140

Open Simply007 opened 4 years ago

Simply007 commented 4 years ago

Description

When using a Pipeline to generate a page using Razor view with a strongly typed view model - LandingPage - I am getting an error:

Error:

Content/PostProcess » RenderContentPostProcessTemplates » ExecuteIf » ExecuteIf » RenderRazor » The model item passed into the ViewDataDictionary is of type 'Statiq.Common.Document', but this ViewDataDictionary instance requires a model item of type 'Jamstack.On.Dotnet.Models.LandingPage

Version of Statiq.Razor: 1.0.0-beta.27 I am using Kontent.Static module to pull data from Kentico Kontent headless CMS.

Pipeline

public class Index : Pipeline
    {
        public Index(IDeliveryClient client)
        {
            InputModules = new ModuleList
            {
                // Load home page
                new Kontent<LandingPage>(client)
                    .WithQuery(new EqualsFilter("system.codename", "home_page")), 
                // Set the output path for each article
                new SetDestination(Config.FromDocument((doc, ctx)
                  => new NormalizedPath($"index.html"))),
            };

            ProcessModules = new ModuleList
            {
                new MergeContent(new ReadFiles("Index.cshtml")),
                new RenderRazor()
                    .WithModel(Config.FromDocument((document, context) => 
                        document.AsKontent<LandingPage>()))  // << PROBLEMATIC PART
            };

            OutputModules = new ModuleList {
                new WriteFiles()
            };
        }
    }

document.AsKontent<LandingPage>() returns LandingPage, so there should be no problem in the Razor template rendering.

Template

@model Jamstack.On.Dotnet.Models.LandingPage

<h1>@Model.Headline</h1>
<p>Hello from my Statiq page.</p>

@daveaglick - I have invited you to the repository with the code, so that you could check the code and the log,

[DBUG] Archives/PostProcess » ExecuteSwitch » RenderContentPostProcessTemplates » ExecuteIf » Starting module execution... (0 input document(s))
[ERRO] Content/PostProcess » RenderContentPostProcessTemplates » ExecuteIf » ExecuteIf » RenderRazor » The model item passed into the ViewDataDictionary is of type 'Statiq.Common.Document', but this ViewDataDictionary instance requires a model item of type 'Jamstack.On.Dotnet.Models.LandingPage'.
[DBUG] Archives/PostProcess » ExecuteSwitch » RenderContentPostProcessTemplates » ExecuteIf » Finished module execution (0 output document(s), 4 ms)
[DBUG] Archives/PostProcess » ExecuteSwitch » RenderContentPostProcessTemplates » ExecuteIf » Starting module execution... (0 input document(s))
[DBUG] Archives/PostProcess » ExecuteSwitch » RenderContentPostProcessTemplates » ExecuteIf » Finished module execution (0 output document(s), 0 ms)
[DBUG] Archives/PostProcess » ExecuteSwitch » RenderContentPostProcessTemplates » Finished module execution (0 output document(s), 6 ms)
[DBUG] Archives/PostProcess » ExecuteSwitch » Finished module execution (0 output document(s), 8 ms)
[INFO] <- Archives/PostProcess » Finished Archives PostProcess phase execution (0 output document(s), 8 ms)
[INFO] -> Archives/Output » Starting Archives Output phase execution... (0 input document(s), 2 module(s))
[DBUG] Archives/Output » FilterDocuments » Starting module execution... (0 input document(s))
[DBUG] Archives/Output » FilterDocuments » Finished module execution (0 output document(s), 0 ms)
[DBUG] Archives/Output » WriteFiles » Starting module execution... (0 input document(s))
[DBUG] Archives/Output » WriteFiles » Finished module execution (0 output document(s), 0 ms)
[INFO] <- Archives/Output » Finished Archives Output phase execution (0 output document(s), 0 ms)
[DBUG] Sitemap/PostProcess » ExecuteIf » GenerateSitemap » Finished module execution (1 output document(s), 24 ms)
[DBUG] Sitemap/PostProcess » ExecuteIf » Finished module execution (1 output document(s), 25 ms)
[INFO] <- Sitemap/PostProcess » Finished Sitemap PostProcess phase execution (1 output document(s), 25 ms)
[INFO] -> Sitemap/Output » Starting Sitemap Output phase execution... (1 input document(s), 1 module(s))
[DBUG] Sitemap/Output » WriteFiles » Starting module execution... (1 input document(s))
[DBUG] Sitemap/Output » WriteFiles » Wrote file /home/runner/work/jamstackon.net/jamstackon.net/output/sitemap.xml from 
[DBUG] Sitemap/Output » WriteFiles » Finished module execution (1 output document(s), 0 ms)
[INFO] <- Sitemap/Output » Finished Sitemap Output phase execution (1 output document(s), 0 ms)
[DBUG] Exception while executing pipeline Content/PostProcess: System.InvalidOperationException: The model item passed into the ViewDataDictionary is of type 'Statiq.Common.Document', but this ViewDataDictionary instance requires a model item of type 'Jamstack.On.Dotnet.Models.LandingPage'.
   at Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary.EnsureCompatible(Object value)
   at Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary..ctor(ViewDataDictionary source, Object model, Type declaredModelType)
   at lambda_method(Closure , ViewDataDictionary )
   at Microsoft.AspNetCore.Mvc.Razor.RazorPagePropertyActivator.CreateViewDataDictionary(ViewContext context)
   at Microsoft.AspNetCore.Mvc.Razor.RazorPagePropertyActivator.Activate(Object page, ViewContext context)
   at Microsoft.AspNetCore.Mvc.Razor.RazorPageActivator.Activate(IRazorPage page, ViewContext context)
   at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderPageCoreAsync(IRazorPage page, ViewContext context)
   at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderPageAsync(IRazorPage page, ViewContext context, Boolean invokeViewStarts)
   at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderAsync(ViewContext context)
   at Statiq.Razor.RazorCompiler.RenderPageAsync(RenderRequest request)
   at Statiq.Razor.RazorService.RenderAsync(RenderRequest request)
   at Statiq.Razor.RenderRazor.<>c__DisplayClass14_0.<<ExecuteContextAsync>g__RenderDocumentAsync|1>d.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at Statiq.Common.ParallelAsyncExtensions.ParallelSelectAsync[TSource,TResult](IEnumerable`1 items, Func`2 asyncSelector, CancellationToken cancellationToken)
   at Statiq.Razor.RenderRazor.ExecuteContextAsync(IExecutionContext context)
   at Statiq.Common.Module.ExecuteAsync(IExecutionContext context)
   at Statiq.Common.Module.ExecuteAsync(IExecutionContext context)
   at Statiq.Core.Engine.ExecuteModulesAsync(ExecutionContextData contextData, IExecutionContext parent, IEnumerable`1 modules, ImmutableArray`1 inputs, ILogger logger)
[DBUG] Content/Output » Skipping pipeline due to dependency error
[DBUG] AnalyzeContent/Input » Skipping pipeline due to dependency error
[DBUG] AnalyzeContent/Process » Skipping pipeline due to dependency error
[DBUG] AnalyzeContent/PostProcess » Skipping pipeline due to dependency error
[DBUG] AnalyzeContent/Output » Skipping pipeline due to dependency error
daveaglick commented 4 years ago

Thanks for the detailed bug report! I can reproduce locally and it certainly looks like there's a mismatch between what the page is expecting (a LandingPage) and the model in the view data (which is definitely a Statiq document):

image

First step is to replicate in a failing test...

daveaglick commented 4 years ago

Okay, tracked this one down. The problem is that Statiq Web is being used in combination with custom pipelines. While the Index pipeline defined explicitly does pass in a custom view model, Statiq Web also executes a whole bunch of other convention-based pipelines, one of which also attempts to render Razor files. When that pipeline runs, it doesn't have the same .WithModel() call that the RenderRazor module in your Index pipeline has, so it gets to the Razor page and it's @model directive and there's a mismatch because that RenderRazor sets test model to the current document (the default behavior).

Even if this problem hadn't exhibited in this way, it still would have caused problems having two pipelines process the same files. There's a few different things you could do - let me know which is preferred and I'll walk you through it:

public static Bootstrapper AddWeb(this Bootstrapper boostrapper) =>
  boostrapper
    .AddPipelines(typeof(BootstrapperFactoryExtensions).Assembly)
    .AddHostingCommands()
    .AddWebServices()
    .AddInputPaths()
    .AddExcludedPaths()
    .SetOutputPath()
    .AddThemePaths()
    .AddDefaultWebSettings()
    .AddWebAnalyzers()
    .ConfigureEngine(e => e.LogAndCheckVersion(typeof(BootstrapperExtensions).Assembly, "Statiq Web", WebKeys.MinimumStatiqWebVersion));

You could each of those directly and omit the .AddPipelines() call. That's a maintenance burden though because if the set of stuff CreateWeb() calls expands you'll have to take note and also expand it. You could also use the ConfigureEngine() bootstrapper extension to iterate the pipelines once they're all added and remove any you don't want.

Turning off or removing the Statiq Web pipelines isn't great because they do a lot for you, so if you want to keep using them you could also...

The rendering calls in Statiq Web use something called templates that keeps them modular and extensible. Basically a template is a media type key with a module definition for those types of documents. The existing one looks like this: image

You could customize that like this:

.ConfigureTemplates(templates =>
{
    ((RenderRazor)templates[MediaTypes.Razor].Module)
        .WithModel(Config.FromDocument((document, context) => 
            document.AsKontent<LandingPage>()));
})

Of course you'd probably want to change the module based on the document path or something, but that shows you how to adjust templates.

It looks like you're doing other stuff in that Index pipeline though, so another option is...

You can exclude a document from processing by setting Excluded: true in it's front matter (which means you'd also need to process that front matter out of the file with the ExtractFrontMatter module, unless you used a sidecar file to set the Excluded metadata). That will exclude the document from the built-in pipelines but you can still use it in your own pipelines. This probably seems the most like what you want to do.

Did all that makes sense? Happy to clarify further or help work though how to set any of these strategies up.

daveaglick commented 4 years ago

Thought of another approach that might work even better. If you name the index file _Index.cshtml with an underscore the built-in Statiq Web pipelines will ignore it. Then use a SetDestination module to change the destination to Index.html after your RenderRazor and you should be good to go.

Simply007 commented 4 years ago

Thanks a lot @daveaglick!

For now, I will stick with the _Index.cshtml solution. I will consider using sidecar file to exclude from processing if I really need to have Index.cshtm as a file name, but it is not an issue for me now. THX!

Simply007 commented 4 years ago

Internal link: https://github.com/Kentico/statiq-kontent-collaboration/issues/16

After discussion with @daveaglick, I am opening up the issue with a more general solution proposal:

Let's switch this issue from the bug to enhancement request and define the functionality.

petrsvihlik commented 4 years ago

I read through the issue and I'm still wondering why my or @alanta's custom pipelines work fine...

Simply007 commented 4 years ago

I read through the issue and I'm still wondering why my or @alanta's custom pipelines work fine...

It might be because I am using .CreateWeb(args) in Bootstrapper, but you and alanta is using .CreateDefault(args).

daveaglick commented 4 years ago

That's exactly right. Remember, this isn't so much a case of it not working, as it working too many times.

The .CreateDefault(args) creates a boostrapper and populates it with the default set of Statiq Framework functionality (like reading pipelines via reflection, getting settings from environment variables, etc.). Statiq Framework doesn't include any built-in pipelines though so the custom pipeline is the only one trying to read the index files.

On the other hand, .CreateWeb(args) calls .CreateDefault(args) internally, but also adds additional functionality from Statiq Web including the built-in Statiq Web pipelines. One of those reads .cshtml files so it picks up the index file which is expecting a custom model, but because that built-in Statiq Web pipelines doesn't know anything about setting the custom model in its own RenderRazor module, the custom model never gets set and the Razor engine crashes.

Simply007 commented 3 years ago

Just for a fill context:

In the end, I have decided to place the "top-level" templates - used for pages themselves, where I can specify the template manually store in _partials/TEMPLATE.cshtml (i.e. /input/_partials/LandingPage.cshtml) and use display templates with sidecar file, because:

Shared/DisplayTemplates does require the name same as the Model provided in order to use automatic matching to models when a Structure rich text rendering is used.

FYI: I have submitted a separate issue for Pipeline specific ViewModel discovery #148 - it is a bit related.