Skip to content

BlockPreview Analysis + NuGet RCL Architecture Plan

Date: 2026-02-28 Status: PROPOSAL -- Architecture design for Phase 6


PART 1: BlockPreview Deep Analysis

1.1 Why Does BlockPreview Require Generated Models (ModelsBuilder)?

The hard requirement is in BlockPreviewApiController.CheckGeneratedModelsExist():

// File: src/Umbraco.Community.BlockPreview/Controllers/BlockPreviewApiController.cs (line 515-521)
private bool CheckGeneratedModelsExist()
{
    return _runtimeCache.GetCacheItem(Constants.CacheKeys.GeneratedModels, () =>
    {
        return _typeFinder.FindClassesWithAttribute<PublishedModelAttribute>().Any();
    }, CacheDuration);
}

This method is called as the FIRST thing in every preview endpoint (PreviewGridBlock, PreviewListBlock, PreviewRichTextMarkup). If it returns false, the preview immediately returns a warning:

"Strongly typed models must be generated and exist on disk for BlockPreview to work."

Why it needs them -- the rendering pipeline:

  1. BlockPreviewService.FindBlockType() (line 483-491) calls _blockEditorConverter.GetModelType(contentType.Key) to find the C# class for each content type. If GetModelType() returns typeof(IPublishedElement) (the "no model exists" fallback), it returns null and the preview fails with "Generated model(s) could not be found."

  2. BlockModelFactory.CreateModel() (line 31-35) uses reflection to invoke the constructor (IPublishedElement, IPublishedValueFallback) on the generated model type. This constructor pattern is specific to ModelsBuilder-generated classes.

  3. BlockModelFactory.CreateBlockItem() (line 38-57) creates generic BlockGridItem<TContent, TSettings> / BlockListItem<TContent, TSettings> instances using the generated model type as the generic parameter.

What happens with IPublishedElement / untyped BlockGridItem?

Views that use @model BlockGridItem (non-generic) or @inherits UmbracoViewPage<BlockGridItem> work fine for frontend rendering because Umbraco's built-in pipeline handles the model binding. But BlockPreview constructs the model from raw JSON in an API controller context (outside the normal rendering pipeline), so it needs the concrete generated type to create the strongly-typed block instances.

Which ModelsBuilder mode is required?

The CheckGeneratedModelsExist() method looks for types with [PublishedModel] attribute using ITypeFinder. This works with:

Mode Works? Notes
InMemoryAuto YES Types are generated in-memory at startup, available via ITypeFinder
SourceCodeAuto YES Types generated to disk on startup, compiled into assembly
SourceCodeManual YES Types generated manually, compiled into assembly
Nothing (disabled) NO No generated types exist

The error message specifically says: "BlockPreview requires strongly-typed models. Please configure ModelsBuilder with SourceCodeAuto or SourceCodeManual mode." However, InMemoryAuto also works because the check uses ITypeFinder which scans loaded assemblies.

1.2 How Does BlockPreview Work?

NuGet Package: Umbraco.Community.BlockPreview (single package, v5.x targets Umbraco 17+)

Project type: Razor Class Library (Microsoft.NET.Sdk.Razor) -- ships both C# backend and compiled TypeScript frontend (Lit web components).

Registration: Auto-registers via IComposer (BlockPreviewComposer) -- no explicit registration needed in Program.cs. Optional configuration via AddBlockPreview() extension method or appsettings.json.

Architecture flow:

Backoffice (TypeScript/Lit Web Component)
  --> POST /umbraco/block-preview/api/v1.0/preview/grid
    --> BlockPreviewApiController.PreviewGridBlock()
      --> CheckGeneratedModelsExist()  [gate: must have ModelsBuilder types]
      --> GetPublishedContent(nodeKey)  [get the parent page from cache]
      --> SetupPublishedRequest(culture) [setup umbraco context + routing]
      --> BlockPreviewService.RenderGridBlock()
        --> BlockDataConverter.DeserializeBlockGrid(blockData) [parse JSON]
        --> BlockDataConverter.ConvertToElement(contentData)  [create IPublishedElement]
        --> FindBlockType(contentElement.ContentType) [lookup generated model type]
        --> BlockModelFactory.CreateBlockInstance() [create BlockGridItem<TContent,TSettings>]
        --> BlockViewRenderer.RenderAsync()
          --> Try ViewComponent first (by PascalCase/camelCase alias)
          --> Fallback to partial view (by content type alias)
      --> CleanUpMarkup() [disable links, forms for backoffice safety]

View resolution: Uses BlockPreviewViewResolver which caches view paths. Default search locations: - /Views/Partials/blockgrid/Components/{0}.cshtml - /Views/Partials/blocklist/Components/{0}.cshtml - /Views/Partials/richtext/Components/{0}.cshtml

The {0} placeholder is replaced with the content type alias (e.g., heroBlock). Both camelCase and PascalCase are tried.

ViewComponent support: If a ViewComponent exists with a name matching the content type alias (PascalCase or camelCase), it takes precedence over partial views.

Key extension methods for views: - @await Html.GetPreviewBlockGridItemsHtmlAsync(Model) -- replaces GetBlockGridItemsHtmlAsync to handle preview mode - @await Html.GetPreviewBlockGridItemAreasHtmlAsync(Model) -- replaces GetBlockGridItemAreasHtmlAsync - Context.Request.IsBlockPreviewRequest() -- check if rendering inside backoffice preview

1.3 Impact on Our Project

Current view pattern (127 blockgrid + 8 blocklist component views):

@model BlockGridItem     <-- non-generic, using IPublishedElement
@{
    var content = Model.Content;
    var value = content.Value<string>("propertyAlias");
}

What BlockPreview example views use:

@inherits UmbracoViewPage<BlockGridItem<HeroBlock, BlockSettings>>  <-- strongly-typed generic
@{
    var value = Model.Content?.Headline;   <-- direct property access
}

The critical question: Can BlockPreview work with our current @model BlockGridItem pattern?

Answer: YES, with one caveat. BlockPreview renders views with the model set to a BlockGridItem<TContent> or BlockGridItem<TContent, TSettings> instance. ASP.NET MVC model binding can downcast this to the base BlockGridItem type -- the generic inherits from the non-generic. So our views with @model BlockGridItem WILL render correctly because:

  1. BlockGridItem<HeroBlock> IS-A BlockGridItem
  2. Model.Content returns IPublishedElement in both cases
  3. content.Value<string>("propertyAlias") works on any IPublishedElement

BUT: BlockPreview still needs the generated models to exist. Even though our views don't use them directly, the rendering pipeline (FindBlockType, CreateModel, CreateBlockItem) needs the concrete types to construct the block instances. The views receive the constructed instance as BlockGridItem (the base type), which works.

Required changes for our project:

Category Change Required? Details
127 blockgrid views NO @model BlockGridItem pattern works as-is
8 blocklist views NO Same -- @model BlockListItem pattern works
ModelsBuilder mode YES Must enable InMemoryAuto, SourceCodeAuto, or SourceCodeManual
blockgrid/default.cshtml YES Replace GetBlockGridItemsHtmlAsync with GetPreviewBlockGridItemsHtmlAsync
blockgrid/areas.cshtml YES Replace GetBlockGridItemAreaHtmlAsync with GetPreviewBlockGridItemAreaHtmlAsync
blockgrid/area.cshtml YES Replace GetBlockGridItemsHtmlAsync with GetPreviewBlockGridItemsHtmlAsync
_ViewImports.cshtml YES Add @using Umbraco.Community.BlockPreview.Extensions
NuGet reference YES Add Umbraco.Community.BlockPreview package reference
appsettings.json YES Add BlockPreview section with Enabled: true and stylesheet paths

Recommended ModelsBuilder mode: InMemoryAuto

This is the lowest-friction option: - No generated files to manage or commit - Models are auto-generated at startup from the Umbraco schema - Works with ITypeFinder (satisfies BlockPreview's gate check) - No impact on our existing @model BlockGridItem views - No need to regenerate models after content type changes

SourceCodeManual would only be needed if we wanted to ship pre-generated models in a NuGet package for compile-time type safety. For BlockPreview alone, InMemoryAuto is sufficient.


PART 2: NuGet RCL Architecture

2.1 Package Structure (3 packages)

Progress.Baseline.sln
|
+-- src/
|   +-- Progress.Baseline.Core/              <-- NuGet: Progress.Baseline.Core
|   |   +-- Extensions/
|   |   |   +-- ServiceCollectionExtensions.cs
|   |   +-- Interfaces/
|   |   |   +-- ICookieSettings.cs
|   |   |   +-- IFooterSettings.cs
|   |   |   +-- IGoogleTagManagerSettings.cs
|   |   |   +-- IHomepageSliderSettings.cs
|   |   |   +-- IMenuSettings.cs
|   |   |   +-- ISeoSettings.cs
|   |   |   +-- ISiteStyleSettings.cs
|   |   |   +-- IWebsiteSettings.cs
|   |   +-- Adapters/
|   |   |   +-- CookieSettingsAdapter.cs
|   |   |   +-- FooterSettingsAdapter.cs
|   |   |   +-- (etc.)
|   |   +-- Services/
|   |   |   +-- ISiteSettingsService.cs
|   |   |   +-- SiteSettingsService.cs
|   |   |   +-- IDictionaryService.cs
|   |   |   +-- DictionaryService.cs
|   |   |   +-- IArticleService.cs
|   |   |   +-- ArticleService.cs
|   |   |   +-- ICalculatorService.cs
|   |   |   +-- CalculatorService.cs
|   |   |   +-- IGlobalNotificationsService.cs
|   |   |   +-- GlobalNotificationsService.cs
|   |   |   +-- IGlobalCookiesService.cs
|   |   |   +-- GlobalCookiesService.cs
|   |   |   +-- IHeadlessContentService.cs
|   |   |   +-- HeadlessContentService.cs
|   |   |   +-- ITwitterService.cs
|   |   |   +-- TwitterService.cs
|   |   +-- ViewModels/
|   |   |   +-- ArticleResultSet.cs
|   |   |   +-- CookiesList.cs
|   |   |   +-- HeadlessContent.cs
|   |   |   +-- NotificationItem.cs
|   |   |   +-- TweetSearchResponse.cs
|   |   +-- Helpers/
|   |   |   +-- CssHelper.cs
|   |   |   +-- ScriptHelper.cs
|   |   +-- PropertyValueConverters/
|   |   |   +-- OpeningSoonValueConverter.cs
|   |   +-- Controllers/
|   |   |   +-- CalculatorApiController.cs
|   |   +-- Security/
|   |   |   +-- ConfigureTwoFactorRememberMeCookieOptions.cs
|   |   |   +-- ForcedTwoFactorBackOfficeUserStore.cs
|   |   |   +-- GoogleAuthenticatorTwoFactorProvider.cs
|   |   |   +-- ITwoFactorSettingsService.cs
|   |   |   +-- TwoFactorAuthComposer.cs
|   |   |   +-- TwoFactorSettingsService.cs
|   |   |   +-- TwoFactorSetupController.cs
|   |   |   +-- TwoFactorUserGroupOptions.cs
|   |   +-- Composing/
|   |   |   +-- BaselineServiceComposer.cs   <-- auto-registers all services via IComposer
|   |   +-- Progress.Baseline.Core.csproj    <-- SDK: Microsoft.NET.Sdk (class library)
|   |
|   +-- Progress.Baseline.Web/              <-- NuGet: Progress.Baseline.Web
|   |   +-- Extensions/
|   |   |   +-- BlockGridExtensions.cs
|   |   |   +-- EnumerableExtensions.cs
|   |   |   +-- HtmlExtensions.cs
|   |   +-- ViewComponents/
|   |   |   +-- CookiesViewComponent.cs
|   |   |   +-- HelpViewComponent.cs
|   |   |   +-- LoginStatusViewComponent.cs
|   |   |   +-- NotificationViewComponent.cs
|   |   |   +-- PrivacyViewComponent.cs
|   |   |   +-- TermsConditionsViewComponent.cs
|   |   |   +-- TwitterViewComponent.cs
|   |   +-- Views/                           <-- ALL Razor views (RCL embedded)
|   |   |   +-- _ViewImports.cshtml
|   |   |   +-- master.cshtml
|   |   |   +-- home.cshtml
|   |   |   +-- standardPage.cshtml
|   |   |   +-- article.cshtml
|   |   |   +-- (all 46 page templates)
|   |   |   +-- Partials/
|   |   |   |   +-- SiteLayout/
|   |   |   |   |   +-- header.cshtml
|   |   |   |   |   +-- footer.cshtml
|   |   |   |   |   +-- (all 18 SiteLayout partials)
|   |   |   |   +-- blockgrid/
|   |   |   |   |   +-- default.cshtml
|   |   |   |   |   +-- items.cshtml
|   |   |   |   |   +-- areas.cshtml
|   |   |   |   |   +-- area.cshtml
|   |   |   |   |   +-- BootstrapGrid.cshtml
|   |   |   |   |   +-- _MultiSectionGrid.cshtml
|   |   |   |   |   +-- _GridHelpers.cshtml
|   |   |   |   |   +-- Components/
|   |   |   |   |   |   +-- (all 127 component partials)
|   |   |   |   +-- blocklist/
|   |   |   |   |   +-- default.cshtml
|   |   |   |   |   +-- Components/
|   |   |   |   |   |   +-- (all 8 component partials)
|   |   |   |   +-- (all other partial subdirectories)
|   |   |   +-- Shared/
|   |   |   |   +-- Components/              <-- ViewComponent default views
|   |   +-- wwwroot/                          <-- Static assets (RCL embedded)
|   |   |   +-- vendor/                       <-- bootstrap, jquery, slick, etc.
|   |   |   +-- Scripts/                      <-- custom JS
|   |   |   +-- content/                      <-- shared CSS (CommonV2, navigation, etc.)
|   |   |   +-- css/                          <-- shared CSS (RTE, blockgrid)
|   |   +-- Progress.Baseline.Web.csproj      <-- SDK: Microsoft.NET.Sdk.Razor (RCL)
|   |
|   +-- Progress.CustomPropertyEditors/      <-- NuGet: Progress.CustomPropertyEditors
|   |   +-- src/                              <-- TypeScript sources
|   |   +-- wwwroot/App_Plugins/              <-- Vite build output (RCL embedded)
|   |   +-- Progress.CustomPropertyEditors.csproj  <-- SDK: Microsoft.NET.Sdk.Razor (RCL)
|   |
|   +-- ProgressLoanCalculator/              <-- NuGet: Progress.LoanCalculator
|       +-- (existing calculator code)
|       +-- ProgressLoanCalculator.csproj     <-- SDK: Microsoft.NET.Sdk (class library)
|
+-- tests/
    +-- Progress.Baseline.Core.Tests/
    +-- Progress.Baseline.Web.Tests/

2.2 Package Dependencies

Progress.Baseline.Web (RCL)
  ├── Progress.Baseline.Core
  ├── Progress.CustomPropertyEditors
  ├── Progress.LoanCalculator
  ├── Umbraco.Cms (17.x)
  ├── Umbraco.Community.BlockPreview (5.x)
  ├── Umbraco.Community.UmbNav (4.x)
  ├── Umbraco.Forms (17.x)
  └── Vokseverk.ColorSelector (6.x)

Progress.Baseline.Core (Class Library)
  ├── Umbraco.Cms.Web.Common (17.x)
  ├── Umbraco.Community.UmbNav.Core (4.x)
  ├── GoogleAuthenticator (3.x)
  └── HtmlAgilityPack (1.x)

Progress.CustomPropertyEditors (RCL)
  ├── Umbraco.Forms (17.x)
  └── Progress.LoanCalculator

Progress.LoanCalculator (Class Library)
  └── (no external dependencies)

2.3 NuGet Package Definitions

Progress.Baseline.Core.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PackageId>Progress.Baseline.Core</PackageId>
    <Version>1.0.0</Version>
    <Authors>Double</Authors>
    <Company>Double</Company>
    <Description>Core services, interfaces, adapters, helpers, controllers, and security
    for Progress CMS baseline projects.</Description>
    <PackageTags>Umbraco;CMS;Baseline;Progress;CreditUnion</PackageTags>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Umbraco.Cms.Web.Common" Version="17.1.0" />
    <PackageReference Include="Umbraco.Community.UmbNav.Core" Version="4.0.2" />
    <PackageReference Include="GoogleAuthenticator" Version="3.2.0" />
    <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
    <PackageReference Include="Umbraco.Headless.Client.Net" Version="1.5.0" />
  </ItemGroup>
</Project>

Progress.Baseline.Web.csproj

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
    <StaticWebAssetBasePath>/</StaticWebAssetBasePath>
    <PackageId>Progress.Baseline.Web</PackageId>
    <Version>1.0.0</Version>
    <Authors>Double</Authors>
    <Company>Double</Company>
    <Description>Razor views, ViewComponents, static assets, and extensions
    for Progress CMS baseline projects. Includes 127 blockgrid + 8 blocklist
    + 18 SiteLayout + 46 page template views.</Description>
    <PackageTags>Umbraco;CMS;Baseline;Progress;CreditUnion;RCL</PackageTags>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
    <CopyRazorGenerateFilesToPublishDirectory>true</CopyRazorGenerateFilesToPublishDirectory>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Umbraco.Cms" Version="17.1.0" />
    <PackageReference Include="Umbraco.Community.BlockPreview" Version="5.0.0" />
    <PackageReference Include="Umbraco.Community.UmbNav" Version="4.0.2" />
    <PackageReference Include="Umbraco.Forms" Version="17.0.3" />
    <PackageReference Include="Vokseverk.ColorSelector" Version="6.0.0" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Progress.Baseline.Core\Progress.Baseline.Core.csproj" />
    <ProjectReference Include="..\Progress.CustomPropertyEditors\Progress.CustomPropertyEditors.csproj">
      <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
    </ProjectReference>
    <ProjectReference Include="..\ProgressLoanCalculator\ProgressLoanCalculator\ProgressLoanCalculator.csproj" />
  </ItemGroup>
</Project>

2.4 How View Override Works in ASP.NET Core RCL

ASP.NET Core has a built-in convention for Razor Class Libraries: local project views take precedence over RCL views at the same path. This is exactly what we need.

Resolution order: 1. {ClientProject}/Views/standardPage.cshtml -- if present, this wins 2. Progress.Baseline.Web/Views/standardPage.cshtml -- RCL fallback

Client override example:

Client project (ProgressCU):
  Views/
    home.cshtml                 <-- OVERRIDES baseline home.cshtml
    Partials/
      blockgrid/
        Components/
          heroBlock.cshtml      <-- OVERRIDES baseline heroBlock.cshtml

Everything else falls through to the RCL baseline views.

This means: - 163 credit unions share the baseline views from the NuGet package - Any credit union can override ANY view by creating it at the same path - No configuration needed -- ASP.NET Core handles it automatically - Updates to baseline views ship via NuGet version bump

2.5 Client Project Structure (Minimal)

ProgressCU/                         <-- One per credit union
  ProgressCU.sln
  src/
    ProgressCU.Web/
      Program.cs                    <-- Minimal: 10-15 lines
      appsettings.json              <-- Connection strings, API keys, client config
      appsettings.Development.json
      appsettings.Production.json
      wwwroot/
        cssCreditUnion/
          ProgressStyles.css        <-- Client-specific CSS theme
        images/
          logo.png                  <-- Client-specific images
      Views/                        <-- ONLY override views (optional)
        home.cshtml                 <-- Only if client needs different home page
      ProgressCU.Web.csproj

Client Program.cs (Minimal)

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// One line registers ALL baseline services, composing, 2FA, etc.
builder.Services.AddProgressBaseline(builder.Configuration);

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddComposers()
    .AddBlockPreview(options =>
    {
        options.BlockGrid = new() { Enabled = true, Stylesheets = ["/content/CommonV2.css"] };
        options.BlockList = new() { Enabled = true };
    })
    .Build();

WebApplication app = builder.Build();

app.UseProgressBaseline();  // Forwarded headers, URL rewrites, etc.
app.UseUmbraco()
    .WithMiddleware(u => { u.UseBackOffice(); u.UseWebsite(); })
    .WithEndpoints(u =>
    {
        u.UseBackOfficeEndpoints();
        u.UseWebsiteEndpoints();
        u.EndpointRouteBuilder.MapControllers();
    });

await app.RunAsync();

Client .csproj (Minimal)

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Progress.Baseline.Web" Version="1.0.0" />
  </ItemGroup>
</Project>

That is it. One NuGet reference. Everything else comes from the RCL.

2.6 Service Registration Pattern

In Progress.Baseline.Core:

// Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddProgressBaseline(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Core services
        services.AddScoped<ISiteSettingsService, SiteSettingsService>();
        services.AddScoped<IDictionaryService, DictionaryService>();
        services.AddScoped<IArticleService, ArticleService>();
        services.AddScoped<ICalculatorService, CalculatorService>();
        services.AddScoped<ILoanCalculatorEstimator, LoanCalculatorEstimator>();
        services.AddScoped<IHeadlessContentService, HeadlessContentService>();
        services.AddScoped<IGlobalNotificationsService, GlobalNotificationsService>();
        services.AddScoped<IGlobalCookiesService, GlobalCookiesService>();
        services.AddScoped<ITwitterService, TwitterService>();
        services.AddHttpClient();
        services.AddControllers();

        // Security / 2FA
        services.AddSingleton<IBackOfficeTwoFactorOptions, TwoFactorUserGroupOptions>();

        // Optional cleanup services
        var cleanupEnabled = configuration.GetValue<bool>(
            "Umbraco:CMS:DatabaseCleanup:Enabled", false);
        if (cleanupEnabled)
            DatabaseCleanupService.CleanupDatabase(configuration);

        var cacheCleanupEnabled = configuration.GetValue<bool>(
            "Umbraco:CMS:CacheCleanup:Enabled", false);
        if (cacheCleanupEnabled)
            CacheCleanupService.CleanupCache(configuration);

        return services;
    }
}

// Middleware extension
public static class ApplicationBuilderExtensions
{
    public static WebApplication UseProgressBaseline(this WebApplication app)
    {
        // Forwarded headers (Azure proxy)
        var forwardedHeaderOptions = new ForwardedHeadersOptions
        {
            ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
        };
        forwardedHeaderOptions.KnownIPNetworks.Clear();
        forwardedHeaderOptions.KnownProxies.Clear();
        app.UseForwardedHeaders(forwardedHeaderOptions);

        if (!app.Environment.IsProduction())
            app.UseDeveloperExceptionPage();

        // URL rewrites
        var rewriteOptions = new RewriteOptions()
            .AddRewrite(@"^news/category/(.*)/?$", "news-events?newsCategory=$1", true)
            .AddRewrite(@"^sitemap\.xml$", "sitemap", true);
        app.UseRewriter(rewriteOptions);

        return app;
    }
}

2.7 ModelsBuilder Integration with BlockPreview

Recommended approach: InMemoryAuto mode

// appsettings.json (client project)
{
  "Umbraco": {
    "CMS": {
      "ModelsBuilder": {
        "ModelsMode": "InMemoryAuto"
      }
    }
  }
}

Why not SourceCodeManual in the NuGet package?

Generated models are schema-specific -- they depend on the exact content types in the Umbraco database. Since each client has a slightly different database (even though the codebase is shared), the models would be different per client. Shipping them in a NuGet package would require: - Generating models from a "canonical" database schema - Ensuring every client database matches that schema exactly - Regenerating and re-publishing the NuGet package every time content types change

This is fragile and unnecessary. InMemoryAuto generates models at runtime from whatever database the app connects to, so it works correctly for all clients with zero maintenance.

Where generated models live with InMemoryAuto: - Nowhere on disk -- they exist only in memory - Generated at application startup from the database schema - Available to ITypeFinder (satisfies BlockPreview's gate check) - Automatically regenerated when content types change in the backoffice

2.8 Build Pipeline

Azure DevOps Pipeline: azure-pipelines.yml

trigger:
  branches:
    include:
      - main
      - release/*
  tags:
    include:
      - 'v*'

pool:
  vmImage: 'windows-latest'

variables:
  buildConfiguration: 'Release'
  nugetFeed: 'https://nuget.double.pt/nuget/Packages'

stages:
- stage: Build
  jobs:
  - job: BuildAndPack
    steps:
    # Setup
    - task: UseDotNet@2
      inputs:
        packageType: 'sdk'
        version: '10.x'

    - task: NodeTool@0
      inputs:
        versionSpec: '22.x'

    # Restore
    - task: DotNetCoreCLI@2
      displayName: 'Restore NuGet packages'
      inputs:
        command: 'restore'
        projects: 'Progress.Baseline.sln'

    # Build Custom Property Editors (TypeScript/Vite)
    - script: |
        cd src/Progress.CustomPropertyEditors
        npm install
        npm run build
      displayName: 'Build TypeScript Custom Editors'

    # Build solution
    - task: DotNetCoreCLI@2
      displayName: 'Build solution'
      inputs:
        command: 'build'
        projects: 'Progress.Baseline.sln'
        arguments: '-c $(buildConfiguration) --no-restore'

    # Run tests
    - task: DotNetCoreCLI@2
      displayName: 'Run tests'
      inputs:
        command: 'test'
        projects: 'tests/**/*.csproj'
        arguments: '-c $(buildConfiguration) --no-build'

    # Pack NuGet packages
    - task: DotNetCoreCLI@2
      displayName: 'Pack Progress.Baseline.Core'
      inputs:
        command: 'pack'
        packagesToPack: 'src/Progress.Baseline.Core/Progress.Baseline.Core.csproj'
        configuration: '$(buildConfiguration)'
        outputDir: '$(Build.ArtifactStagingDirectory)/nuget'

    - task: DotNetCoreCLI@2
      displayName: 'Pack Progress.Baseline.Web'
      inputs:
        command: 'pack'
        packagesToPack: 'src/Progress.Baseline.Web/Progress.Baseline.Web.csproj'
        configuration: '$(buildConfiguration)'
        outputDir: '$(Build.ArtifactStagingDirectory)/nuget'

    - task: DotNetCoreCLI@2
      displayName: 'Pack Progress.CustomPropertyEditors'
      inputs:
        command: 'pack'
        packagesToPack: 'src/Progress.CustomPropertyEditors/Progress.CustomPropertyEditors.csproj'
        configuration: '$(buildConfiguration)'
        outputDir: '$(Build.ArtifactStagingDirectory)/nuget'

    - task: DotNetCoreCLI@2
      displayName: 'Pack Progress.LoanCalculator'
      inputs:
        command: 'pack'
        packagesToPack: 'src/ProgressLoanCalculator/ProgressLoanCalculator/ProgressLoanCalculator.csproj'
        configuration: '$(buildConfiguration)'
        outputDir: '$(Build.ArtifactStagingDirectory)/nuget'

    # Publish artifacts
    - publish: '$(Build.ArtifactStagingDirectory)/nuget'
      artifact: 'NuGetPackages'

- stage: Publish
  condition: and(succeeded(), or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), startsWith(variables['Build.SourceBranch'], 'refs/tags/v')))
  jobs:
  - deployment: PublishNuGet
    environment: 'NuGet-Internal'
    strategy:
      runOnce:
        deploy:
          steps:
          - task: NuGetCommand@2
            displayName: 'Push to internal NuGet feed'
            inputs:
              command: 'push'
              packagesToPush: '$(Pipeline.Workspace)/NuGetPackages/*.nupkg'
              nuGetFeedType: 'external'
              publishFeedCredentials: 'DoubleNuGetFeed'

NuGet.config (for client projects consuming the packages)

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="Double Internal" value="https://nuget.double.pt/nuget/Packages" />
  </packageSources>
  <packageSourceCredentials>
    <Double_Internal>
      <add key="Username" value="nuget" />
      <add key="ClearTextPassword" value="JfwFv3Fv!eeEGHBHkT7EhoFMqCjWP.Ur9Lg" />
    </Double_Internal>
  </packageSourceCredentials>
</configuration>

2.9 Migration Path from Current Structure

Phase 6A: Create Progress.Baseline.Web RCL (2-3 days)

  1. Create new RCL project in the solution:

    dotnet new razorclasslib -n Progress.Baseline.Web -o src/Progress.Baseline.Web
    

  2. Move shared code from www to packages:

  3. Move services (Dictionary, Article, Calculator, etc.) to Progress.Baseline.Core
  4. Move extensions (BlockGrid, Enumerable, Html) to Progress.Baseline.Web
  5. Move ViewComponents to Progress.Baseline.Web
  6. Move Helpers (CssHelper, ScriptHelper) to Progress.Baseline.Core
  7. Move Security folder to Progress.Baseline.Core
  8. Move Controllers to Progress.Baseline.Core
  9. Move PropertyValueConverters to Progress.Baseline.Core
  10. Move ViewModels to Progress.Baseline.Core

  11. Move views to Progress.Baseline.Web/Views/:

  12. All 46 page template views
  13. All 18 SiteLayout partials
  14. All 127 blockgrid component partials
  15. All 8 blocklist component partials
  16. All other partial subdirectories
  17. _ViewImports.cshtml

  18. Move shared static assets to Progress.Baseline.Web/wwwroot/:

  19. vendor/ (bootstrap, jquery, slick, etc.)
  20. Scripts/ (custom JS)
  21. content/ (shared CSS)
  22. css/ (RTE, blockgrid CSS)

  23. Keep in client project (www):

  24. Program.cs (simplified to ~15 lines)
  25. appsettings.json (connection strings, client config)
  26. wwwroot/cssCreditUnion/ (client CSS -- just the one file for this client)
  27. wwwroot/images/ (client-specific images)
  28. Any client-specific view overrides

Phase 6B: Add BlockPreview (1 day)

  1. Add Umbraco.Community.BlockPreview NuGet reference to Progress.Baseline.Web
  2. Update _ViewImports.cshtml with @using Umbraco.Community.BlockPreview.Extensions
  3. Update blockgrid scaffolding views (default.cshtml, areas.cshtml, area.cshtml) to use GetPreview* methods
  4. Add BlockPreview section to appsettings.json template
  5. Enable ModelsBuilder.ModelsMode = InMemoryAuto in appsettings.json

Phase 6C: Package and Publish (1 day)

  1. Set up azure-pipelines.yml
  2. Configure NuGet feed connection
  3. Test pack locally: dotnet pack -c Release
  4. Publish v1.0.0 to internal feed
  5. Create template client project and verify it works

Phase 6D: Create dotnet template (1 day)

Create a dotnet new template so new client projects can be scaffolded:

dotnet new progress-cu -n AcmeCreditUnion --client-css AcmeStyles

This would generate:

AcmeCreditUnion/
  AcmeCreditUnion.sln
  src/
    AcmeCreditUnion.Web/
      Program.cs
      appsettings.json
      appsettings.Development.json
      NuGet.config
      AcmeCreditUnion.Web.csproj
      wwwroot/
        cssCreditUnion/
          AcmeStyles.css

2.10 What Each Package Contains (Summary)

Package Type Contents Size Est.
Progress.Baseline.Core Class Library 16 services, 9 interfaces, 9 adapters, 5 ViewModels, 2 helpers, 1 controller, 8 security classes, 1 property converter ~50 C# files
Progress.Baseline.Web Razor Class Library 269 Razor views, 7 ViewComponents, 3 extension classes, shared CSS/JS/vendor assets ~269 .cshtml + ~2MB static
Progress.CustomPropertyEditors Razor Class Library 27 custom property editors (TypeScript/Vite built output in App_Plugins) ~1MB compiled JS
Progress.LoanCalculator Class Library Calculator engine (APR, loan, mortgage calculations) ~12 C# files

2.11 Version Strategy

  • Major version (2.0.0): Breaking changes to service interfaces, view model shapes, or removed views
  • Minor version (1.1.0): New features, new views, new services (backward compatible)
  • Patch version (1.0.1): Bug fixes, CSS tweaks, template corrections

Use MinVerSDK or Nerdbank.GitVersioning for automatic versioning from git tags.

2.12 Risk Mitigation

Risk Mitigation
View paths don't match between RCL and current project Test with dotnet publish -- RCL embeds views at the same path
Static asset paths break (~/vendor/...) RCL StaticWebAssetBasePath set to / ensures same URL paths
@inject services not available in RCL views Services registered via AddProgressBaseline() are available globally
BlockPreview can't find views in RCL BlockPreview docs confirm RCL support works automatically via IViewLocationExpander
Client overrides for partial views don't work ASP.NET Core convention: local files at same path always win over RCL
163 client CSS files are too many to package Client CSS stays in client project, NOT in the NuGet package
ModelsBuilder InMemoryAuto performance at startup Startup adds ~2-5 seconds for model generation; acceptable for production

Summary Decision Matrix

Decision Choice Rationale
Number of packages 4 (Core, Web, CustomEditors, LoanCalc) Separation of concerns; Web depends on Core but not vice versa
RCL SDK Microsoft.NET.Sdk.Razor for Web + CustomEditors Required for embedding views and static assets
ModelsBuilder mode InMemoryAuto Zero-maintenance, works with BlockPreview, no files to manage
View override mechanism ASP.NET Core built-in RCL convention Client creates file at same path to override; no config needed
Client project size ~5 files (Program.cs, appsettings, NuGet.config, .csproj, client CSS) Everything else from NuGet
BlockPreview compatibility Works with existing @model BlockGridItem pattern No view changes needed; just needs ModelsBuilder enabled
NuGet feed Internal at https://nuget.double.pt/nuget/Packages Already exists, API key available
CI/CD Azure DevOps pipeline Matches existing infrastructure
Migration documentation by Double for Progress Credit Union