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:
Why it needs them -- the rendering pipeline:
-
BlockPreviewService.FindBlockType()(line 483-491) calls_blockEditorConverter.GetModelType(contentType.Key)to find the C# class for each content type. IfGetModelType()returnstypeof(IPublishedElement)(the "no model exists" fallback), it returnsnulland the preview fails with "Generated model(s) could not be found." -
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. -
BlockModelFactory.CreateBlockItem()(line 38-57) creates genericBlockGridItem<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:
BlockGridItem<HeroBlock>IS-ABlockGridItemModel.ContentreturnsIPublishedElementin both casescontent.Value<string>("propertyAlias")works on anyIPublishedElement
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)¶
-
Create new RCL project in the solution:
-
Move shared code from
wwwto packages: - Move services (Dictionary, Article, Calculator, etc.) to
Progress.Baseline.Core - Move extensions (BlockGrid, Enumerable, Html) to
Progress.Baseline.Web - Move ViewComponents to
Progress.Baseline.Web - Move Helpers (CssHelper, ScriptHelper) to
Progress.Baseline.Core - Move Security folder to
Progress.Baseline.Core - Move Controllers to
Progress.Baseline.Core - Move PropertyValueConverters to
Progress.Baseline.Core -
Move ViewModels to
Progress.Baseline.Core -
Move views to
Progress.Baseline.Web/Views/: - All 46 page template views
- All 18 SiteLayout partials
- All 127 blockgrid component partials
- All 8 blocklist component partials
- All other partial subdirectories
-
_ViewImports.cshtml -
Move shared static assets to
Progress.Baseline.Web/wwwroot/: vendor/(bootstrap, jquery, slick, etc.)Scripts/(custom JS)content/(shared CSS)-
css/(RTE, blockgrid CSS) -
Keep in client project (
www): Program.cs(simplified to ~15 lines)appsettings.json(connection strings, client config)wwwroot/cssCreditUnion/(client CSS -- just the one file for this client)wwwroot/images/(client-specific images)- Any client-specific view overrides
Phase 6B: Add BlockPreview (1 day)¶
- Add
Umbraco.Community.BlockPreviewNuGet reference toProgress.Baseline.Web - Update
_ViewImports.cshtmlwith@using Umbraco.Community.BlockPreview.Extensions - Update blockgrid scaffolding views (
default.cshtml,areas.cshtml,area.cshtml) to useGetPreview*methods - Add
BlockPreviewsection toappsettings.jsontemplate - Enable
ModelsBuilder.ModelsMode = InMemoryAutoinappsettings.json
Phase 6C: Package and Publish (1 day)¶
- Set up
azure-pipelines.yml - Configure NuGet feed connection
- Test pack locally:
dotnet pack -c Release - Publish v1.0.0 to internal feed
- 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:
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 |