TemplateBuilder.Editor.Mvc5
1.3.15
dotnet add package TemplateBuilder.Editor.Mvc5 --version 1.3.15
NuGet\Install-Package TemplateBuilder.Editor.Mvc5 -Version 1.3.15
<PackageReference Include="TemplateBuilder.Editor.Mvc5" Version="1.3.15" />
<PackageVersion Include="TemplateBuilder.Editor.Mvc5" Version="1.3.15" />
<PackageReference Include="TemplateBuilder.Editor.Mvc5" />
paket add TemplateBuilder.Editor.Mvc5 --version 1.3.15
#r "nuget: TemplateBuilder.Editor.Mvc5, 1.3.15"
#:package TemplateBuilder.Editor.Mvc5@1.3.15
#addin nuget:?package=TemplateBuilder.Editor.Mvc5&version=1.3.15
#tool nuget:?package=TemplateBuilder.Editor.Mvc5&version=1.3.15
TemplateBuilder.Editor.Mvc5
Current version: 1.3.15
Embed a full Scriban-powered HTML template management UI into any ASP.NET MVC 5 application running on .NET Framework 4.8. Install the package, register a Unity container, wire up two routes — and your users can create, edit, version, compare, preview, and restore templates with reusable snippets, all wrapped in your own site layout.
The same product line as TemplateBuilder.Editor (ASP.NET Core / .NET 8+), rebuilt for the MVC 5 / EF6 / Unity stack.
Screenshots
| Template list | 3-panel editor (light) |
|---|---|
| Live preview | Editor (dark theme) |
|---|---|
Requirements
- .NET Framework 4.8
- ASP.NET MVC 5.x (
System.Web.Mvc5.3.0) - Unity 5.x +
Unity.Mvc51.4.x - Entity Framework 6.x
- SQL Server (any edition — schema is created for you)
- Newtonsoft.Json 13.x, RazorGenerator.Mvc 2.4.x (pulled in automatically)
Note: the editor ships with precompiled Razor views and its own bundled JavaScript/CSS — no
.cshtmlfiles are ever copied into your project.
Quick Start
1. Install
Package Manager Console:
Install-Package TemplateBuilder.Editor.Mvc5
The package ships a
tools/install.ps1that lists the recommended assembly binding redirects (Newtonsoft.Json 13, EntityFramework 6.5.1, System.Text.Json 10) forpackages.config-style projects.
2. Add a connection string
<connectionStrings>
<add name="TemplateDb"
connectionString="Server=.;Database=TemplateBuilder;Trusted_Connection=True;TrustServerCertificate=True;"
providerName="System.Data.SqlClient" />
</connectionStrings>
3. Register in your Unity bootstrapper
using System.Web.Mvc;
using TemplateBuilder.Editor.Mvc5;
using Unity;
using Unity.Mvc5;
public static class UnityConfig
{
public static void RegisterComponents()
{
var container = new UnityContainer();
container.RegisterTemplateBuilderEditor(options =>
{
options.ConnectionString =
System.Configuration.ConfigurationManager.ConnectionStrings["TemplateDb"].ConnectionString;
});
DependencyResolver.SetResolver(new UnityDependencyResolver(container));
}
}
Call UnityConfig.RegisterComponents() from Application_Start (or wherever your app boots).
4. Wire up routing
In RouteConfig.RegisterRoutes, before your conventional catch-all route:
using TemplateBuilder.Editor.Mvc5;
public static void RegisterRoutes(RouteCollection routes)
{
TemplateBuilderEditorRouteConfig.RegisterRoutes(routes); // attribute routes + /TemplateBuilderEditor assets
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional });
}
5. Register the precompiled view engine
The editor's views are compiled into the package assembly (RazorGenerator). Add them to the view engine list in Application_Start:
ViewEngines.Engines.Clear();
ViewEngines.Engines.Add(new PrecompiledMvcEngine(typeof(TemplateBuilder.Editor.Mvc5.UnityContainerExtensions).Assembly));
ViewEngines.Engines.Add(new RazorViewEngine()); // your own views
6. Link the editor assets
The editor's CSS/JS are served from /TemplateBuilderEditor/... and rendered inside a #tb-editor-host container, so they cannot collide with your page's Bootstrap 3 (or other) styles:
<head>
<link href="/TemplateBuilderEditor/css/suneditor.min.css" rel="stylesheet" />
<link href="/TemplateBuilderEditor/css/template-editor.css" rel="stylesheet" />
</head>
<body>
<a href="/Templates">Templates</a>
@RenderBody()
<script src="/TemplateBuilderEditor/js/suneditor.min.js"></script>
<script src="/TemplateBuilderEditor/js/template-editor.js"></script>
</body>
IIS note: If you see native IIS 404s for these asset URLs (no
X-AspNet-Versionheader), register the package's dedicated asset handler in yourWeb.config— see the IIS Static Assets section below.
7. Configure layout (if your host assigns Layout per-view)
By default the editor pages inherit the host's ambient _ViewStart.cshtml-supplied Layout. If your host application assigns Layout per-view explicitly (without a _ViewStart default), set LayoutPath once in your bootstrapper:
container.RegisterTemplateBuilderEditor(options =>
{
options.ConnectionString = ...;
options.LayoutPath = "~/Views/Shared/_Layout.cshtml";
});
The editor renders inside your layout's @RenderBody(), carrying your site's chrome (nav, sidebar, stylesheets). Leave LayoutPath null (the default) if your _ViewStart.cshtml already provides a default Layout — existing integrations see zero behavior change.
LayoutPathmust be an app-relative virtual path, starting with~/and including the.cshtmlextension — exactly what you'd pass toLayout = "..."in a view."~/Views/Shared/_Layout.cshtml"is correct;"Shared/_Layout.cshtml","shared/_layout.cshtml", or"~/Views/Shared/_Layout"(no extension) are not — MVC's view engine only recognizes~/-rooted paths as an exact location, so anything else is searched for as a view name instead and is never found.RegisterTemplateBuilderEditor()throws at startup if the format is wrong, rather than letting/Templatesrender blank at request time. The Setup Diagnostic page (/Templates/_setup) also verifies the configured path resolves to a real file.
8. Run
EF6 migrations apply automatically on first startup — the database and schema are created for you. Navigate to /Templates.
Access Control
By default the editor is open to all users — no authentication is required. To restrict access, add the editor's global authorization filter and configure options.Authorization:
// FilterConfig.RegisterGlobalFilters — protects every editor route
filters.Add(new TemplateBuilderAuthorizationFilter());
Anonymous (default — no change required)
container.RegisterTemplateBuilderEditor(options =>
{
options.ConnectionString = ...;
// options.Authorization.Mode defaults to Anonymous
});
Authenticated users only
Any signed-in user can access the editor.
using TemplateBuilder.Editor.Mvc5.Authorization;
options.Authorization.Mode = TemplateBuilderAuthorizationMode.Authenticated;
Role-based access
A user in any of the listed roles is granted access (OR logic).
using TemplateBuilder.Editor.Mvc5.Authorization;
options.Authorization.Mode = TemplateBuilderAuthorizationMode.Role;
options.Authorization.RoleNames = new[] { "Admin", "Supervisor" };
Custom authorization (escape hatch)
For claims-based or composite rules, register your own IAuthorizationFilter under a name and point the editor at it:
// 1. Register your filter during startup
TemplateBuilderAuthorizationPolicyRegistry.Register(
"TemplateEditorAccess", new MyCustomAuthorizationFilter());
// 2. Point the editor at it
options.Authorization.PolicyName = "TemplateEditorAccess";
What is protected
The filter applies to every editor controller — the full route surface (/Templates/* including Edit, Preview, SaveVersion, Versions, Restore, Duplicate, Validate, ToggleActive, the Snippets API, /Audit, and /_setup).
Author Identity (CreatedBy)
Every TemplateBuilder table that records an author (TemplateVersion.CreatedBy,
SnippetVersion.CreatedBy, snippet usage UsedBy, and the audit log Actor) is stamped
with the current user (imported template versions keep their original author from the
export file), resolved in this order:
options.ActorResolver(your custom resolver, if set)User.Identity.Name"anonymous"
Without configuration the editor stores User.Identity.Name (or "anonymous" when the
request is unauthenticated or the name is empty). Existing records are never backfilled —
legacy rows display "anonymous" in the UI.
Supply your own identity from your existing RegisterTemplateBuilderEditor call — e.g. a
claims value:
container.RegisterTemplateBuilderEditor(options =>
{
options.ConnectionString = connectionString;
// Store the "sub" claim (or any claim / custom user lookup) as the author
options.ActorResolver = ctx => ctx.User?.FindFirst("sub")?.Value;
});
The resolver receives the request's HttpContextBase, so it can read claims, session, or
any of your own services captured in the closure. It runs once per request; a null or
blank result falls back to the chain below it. Values are stored as returned — trim
inside the resolver if your source may carry stray whitespace. The stored value is
truncated to 200 characters (the column limit). Exceptions thrown by your resolver
propagate.
Setup Diagnostic Page
After installation, navigate to /Templates/_setup (requires <compilation debug="true" />; returns 404 otherwise) to verify every integration requirement at once:
| Check | What it detects |
|---|---|
| Database connection | SQL Server reachable with the configured connection string |
MapMvcAttributeRoutes() registered |
Attribute-routed endpoints (Edit, Preview, SaveVersion, Versions) are reachable |
| Static assets serving | Verifies the static-assets route is registered AND all four embedded resources (CSS/JS) exist in the assembly. When IIS is detected, it advises on the handler entry if assets might be blocked by the native StaticFileModule. |
Every failing check shows a one-line fix.
Features
| Feature | Route |
|---|---|
| Template list | GET /Templates |
| Create template | GET/POST /Templates/Create |
| Edit template | GET /Templates/{id}/Edit |
| Save draft version | POST /Templates/{id}/SaveVersion (isActive:false) |
| Save version | POST /Templates/{id}/SaveVersion |
| Version history | GET /Templates/{id}/Versions |
| Version body (for compare) | GET /Templates/{id}/Versions/{versionId}/Body |
| Restore version | POST /Templates/{id}/Restore/{versionId}/{sourceVersionNumber} |
| Live preview | POST /Templates/{id}/Preview |
| Duplicate | POST /Templates/{id}/Duplicate |
| Validate syntax | POST /Templates/{id}/Validate |
| Toggle active | POST /Templates/{id}/ToggleActive |
| List snippets | GET /Templates/Api/Snippets |
| Create snippet | POST /Templates/Api/Snippets |
| Update snippet | PUT /Templates/Api/Snippets/{id} |
| Delete snippet | DELETE /Templates/Api/Snippets/{id} |
| Snippet version history | GET /Templates/Api/Snippets/{id}/Versions |
| Restore snippet version | POST /Templates/Api/Snippets/{id}/Restore/{versionId} |
| Record snippet usage | POST /Templates/Api/Snippets/{id}/Usage?templateId={id} |
| Template audit timeline | GET /Templates/{id}/Audit |
| Global audit log | GET /Audit |
| Audit CSV export | GET /Audit/Export |
| Export template (JSON incl. versions) | GET /Templates/Export/{id} |
| Import template export file | POST /Templates/Import |
| Bulk activate | POST /Templates/BulkActivate |
| Bulk deactivate | POST /Templates/BulkDeactivate |
| Bulk export ZIP | POST /Templates/BulkExport |
| Bulk delete | POST /Templates/BulkDelete |
| Template health check | GET /Templates/{id}/Health |
| Health overview page | GET /Health |
| Health summaries (badges) | GET /Health/Summaries?ids=1,2 |
| Setup check | GET /Templates/_setup (debug only) |
Governance & Compliance
Two-state saves
Templates use a simple two-state save model — there is no review/approval workflow:
- Each version is either Draft or Active (
TemplateVersion.IsActive). Save draft version saves the current body as a new Draft version; Save version saves it as the Active version (the live one). Both kinds of versions carry the same full version history, compare, and restore capabilities. - The editor shows the latest version, with a "Draft version" badge when the latest version is a draft; the version history lists every version with an Active / Draft badge.
- The render API serves the last Active version.
ITemplateEngine.RenderAsync/RenderByNameAsyncthrowTemplateNotFoundException(no such template),TemplateInactiveException(the template is not servable), orNoActiveVersionException(no Active version exists yet) instead of silently rendering a draft. - Template
IsActiveis the servable switch —POST /Templates/{id}/ToggleActive(or the bulk Activate/Deactivate actions) turns serving on/off independently of which version is latest. A template can be active as a whole while its latest version is still a draft.
Audit log (append-only)
Every meaningful action — version saves/restores (draft and active), snippet create/edit/restore/delete — is written to an append-only AuditLog table. Rows are never updated or deleted. (Snippet usage is tracked separately in the SnippetUsages table, not the audit log.)
- Per-template timeline —
GET /Templates/{id}/Audit, also rendered in the editor's Timeline panel (newest first). - Global audit view —
GET /Auditwith filters (entity type, action, actor, date range, search) and paging. - CSV export —
GET /Audit/Exportdownloadstemplate-builder-audit.csvwith columnsOccurredAt,EntityType,EntityId,Action,Actor,Comment(UTF-8 with BOM).
Snippet governance
- Snippets have version history and usage tracking. An edit that changes the body creates a new version; metadata-only edits do not.
GET /Templates/Api/Snippets/{id}/Versionslists history, andPOST /Templates/Api/Snippets/{id}/Restore/{versionId}restores a version — a restore itself creates a new version, so no state is lost. (The initial body is captured as v1 on the first body change; a never-edited snippet has no version rows yet.) - Concurrent snippet edits are rejected with
409via a row-version concurrency token. - Inserting a snippet into a template records usage —
POST /Templates/Api/Snippets/{id}/Usage?templateId={id}— and the snippet list shows "used Nx in M templates".
Lifecycle & Ops
Export / import (dev → prod promotion)
- Export —
GET /Templates/Export/{id}downloads a camelCase JSON document (schemaVersion: 2) containing the template metadata, itsexternalKey(a stable GUID identity assigned at creation), and the full ordered version history — each version carrying itsisActiveflag. The list page has an Export row action;POST /Templates/BulkExportpackages multiple templates into a ZIP with a_summary.jsonmanifest. - Import —
POST /Templates/Import(multipart file upload) matches byexternalKey: templates with a matching key in the target environment get their metadata updated and their versions appended (continuing from the target's next version number); new keys create new templates with original version numbers preserved. Documents withschemaVersion != 2are rejected (v1 exports are not imported); per-versionisActiveflags and the templateisActiveswitch are preserved exactly — nothing is skipped or collapsed. - The import modal on the list page renders per-entry results (created / updated with "N versions appended" / skipped / errors).
SourceView/SourceViewSnapshotare deliberately not exported — they are environment-local schema expectations, not part of the template.
Template health check (field drift vs live schema)
- Bind a template to a SQL view via the Source SQL View select in the editor's Properties panel (saving refreshes a stored snapshot of that view's columns).
GET /Templates/{id}/Health(and the editor's Health button) compares the template's Scribanmodel.*paths against the live view schema and reports findings:column_missing(Critical),column_type_changed/column_length_changed/column_nullability_changed(Warning, from the snapshot),view_missing(Critical), andunbound_tokens(Warning, template uses model fields but no view is bound).GET /Healthis the overview page (Healthy / Warnings / Critical / Unbound stat chips and a per-template finding table); the list page's health badges pollGET /Health/Summaries?ids=….
Bulk operations
- The list page's row checkboxes reveal a bulk toolbar: Activate, Deactivate, Export ZIP, Delete (with confirmation; version history is removed, audit rows remain), and Clear. Each endpoint returns
{ succeeded, failed }so partial failures are visible.
Theming
The editor ships with a light theme by default. A ☀ Light / 🌙 Dark toggle button appears in the CANVAS panel heading and persists your preference in localStorage.
The editor's styles are fully scoped to #tb-editor-host using CSS custom properties — they do not affect the rest of your application. The editing canvas is always white (document-like) regardless of the selected theme.
Template Syntax
Templates use Scriban — access model properties via model.*:
<p>Hello <strong>{{ model.FirstName }}</strong>,</p>
{{ for item in model.Items }}
<p>{{ item.Name }} — {{ item.Price }}</p>
{{ end }}
{{ if model.IsPremium }}
<p>Thank you for being a premium member.</p>
{{ end }}
Live preview and version-comparison output is passed through an HTML sanitizer (HtmlSanitizer), so model.* values cannot inject script or arbitrary markup into your rendered emails/documents. Sanitization happens in the editor's Preview endpoints — when you render templates in your own code, apply IHtmlSanitizerService.Sanitize to the output (as the preview endpoint does).
Render Templates in Code
TemplateBuilder.Editor.Mvc5 includes the rendering engine. Resolve ITemplateEngine from Unity anywhere:
using TemplateBuilder.Domain.Interfaces;
public class WelcomeEmailBuilder
{
private readonly ITemplateEngine _engine;
public WelcomeEmailBuilder(ITemplateEngine engine) => _engine = engine;
public Task<string> BuildAsync(string firstName) =>
_engine.RenderByNameAsync("Welcome Email", new { FirstName = firstName });
}
Available methods: RenderAsync(templateId, model), RenderByNameAsync(name, model), and RenderBodyAsync(body, model) — all supporting both model.* and top-level access.
Database
RegisterTemplateBuilderEditor() runs EF6 MigrateDatabaseToLatestVersion on first access — migrations are bundled with the package. No manual migration steps are required.
DBA-managed database (app login is DML-only)
If your SQL login has no DDL rights (no CREATE TABLE/ALTER — a common enterprise constraint), the app cannot run migrations. Instead:
- Provision the schema once — the package installs a generated SQL script into your project's
Scripts\folder:TemplateBuilder.schema.<version>.sql(e.g.TemplateBuilder.schema.1.3.2.sql). Have your DBA run it against the target database. The script is generated from the package's EF6 migration chain, creates all tables and indexes, and records the migration history so the app considers the database up to date. - Tell the package not to touch DDL — in
RegisterTemplateBuilderEditor:
options.ApplyMigrations = false;
With ApplyMigrations = false the package installs no database initializer and never attempts DDL — the app login needs only DML (SELECT/INSERT/UPDATE/DELETE).
- Upgrading a DBA-managed database — every release ships a new versioned script (e.g.
TemplateBuilder.schema.1.3.3.sql); have the DBA run the new file against the existing database. The runtime never runs migrations on its own.
Note: if you use the Package Manager Console (Update-Database / Add-Migration) for design-time tooling, that still requires a connection string named TemplateBuilderDbContext in your Web.config, as documented in the v1.3.1 notes.
Static Assets
CSS and JS are served automatically from:
/TemplateBuilderEditor/css/suneditor.min.css
/TemplateBuilderEditor/css/template-editor.css
/TemplateBuilderEditor/js/suneditor.min.js
/TemplateBuilderEditor/js/template-editor.js
The static-asset route is registered by TemplateBuilderEditorRouteConfig.RegisterRoutes() and never intercepts URL generation. After upgrading the package, do a hard refresh (Ctrl+Shift+R) to clear cached assets.
IIS Static Assets
Under IIS / IIS Express with the default runAllManagedModulesForAllRequests="false", requests for .css/.js files are handled by IIS's native StaticFileModule before they reach managed code. Your editor asset URLs return a native IIS 404 (no X-AspNet-Version header) because no physical file exists at those paths — the assets are embedded in the package assembly.
The package now ships a dedicated IHttpHandler (TemplateBuilderAssetHandler) that IIS invokes directly, bypassing the module pipeline entirely. The install.ps1 script adds the handler entry to your Web.config automatically during package install (in the Package Manager Console):
<system.webServer>
<handlers>
<add name="TemplateBuilderEditorAssets"
path="TemplateBuilderEditor/*" verb="GET"
type="TemplateBuilder.Editor.Mvc5.TemplateBuilderAssetHandler, TemplateBuilder.Editor.Mvc5"
resourceType="Unspecified" preCondition="integratedMode" />
</handlers>
</system.webServer>
This is preferred — it fixes the 404 for every consumer without changing any site-wide IIS module setting. The templates/_setup diagnostic page now verifies all four embedded resources exist and detects IIS so it can advise when the handler might be needed.
If you prefer not to add the handler entry, the alternative is the site-wide setting:
<system.webServer>
<modules runAllManagedModulesForAllRequests="true" />
</system.webServer>
This forces every request (images, fonts, scripts, CSS) through the managed pipeline — it fixes the editor assets but adds overhead to every static file your site serves. The handler approach is zero-overhead and scoped.
JSON Endpoints & Anti-Forgery
MVC 5 has no header-based anti-forgery built in, so the editor's JSON endpoints (Create, SaveVersion, Preview, Restore, Validate, Duplicate, ToggleActive, SampleData, Snippets) are protected by the package's ValidateJsonAntiForgeryTokenAttribute. The bundled editor JavaScript sends the RequestVerificationToken header automatically — no extra wiring required on your side. Create uses a JSON body (not a form POST), so raw HTML template bodies pass through MVC 5 request validation cleanly on every host.
What's New
v1.3.15
Backported from TemplateBuilder.Editor (ASP.NET Core) v3.0.1/v3.1.0 — findings and fixes
documented in that repo's docs/qa/2026-09-05-qa-agent-triage.md and docs/Recommendations.md.
Two origin findings (a stored-XSS via Html.Raw on the Duplicate button, and an undefined
errMessage() helper) didn't reproduce here and required no change.
- Fixed:
Duplicatehad no server-side validation of the new template name at all — a request with a missing/emptynewNamethrew an unhandledNullReferenceExceptioninstead of a clean validation error.CreateandSaveVersionalso mislabeled an over-200-character name (past the column'snvarchar(200)limit) as "already exists", the same generic message shown for a genuine duplicate. All three actions now share aValidateTemplateNamecheck (required, ≤200 characters) that returns the correctVALIDATION_ERRORbefore anything touches the database. - Fixed:
Importhad no file-size or file-type guard, and read the entire uploaded file into memory unconditionally. It now rejects files over 5 MB and anything without a.jsonextension before parsing. The companion Import dialog JS previously discarded the server's actual error message on any failed import, always showing a generic "Import failed." — it now surfaces the real reason from the response body. - Added: drag-and-drop on the Import dialog — the file input is now wrapped in a dropzone
that accepts a dragged
.template.jsonfile, in addition to the existing browse button. - Added: a Print button on the rendered preview — prints just the preview iframe's own
content via its
contentWindow, not the host page around it. - Added: a confirmation toast after Restore (from version history or the compare view) —
previously the page just reloaded with no feedback that anything happened. The toast is queued
through the reload via
sessionStorageso it still shows up on the freshly-loaded page. - Added: a success toast after bulk export ("Template exported" / "N templates exported") — previously only a failure toast existed.
- Added: a client-side required-name check on Create — an empty template name is now caught instantly with "Template name is required.", without a round-trip to the server.
- Fixed: the toast notification duration was too short (2.5s) to comfortably read — bumped to 4s.
v1.3.14
Backported from a QA pass done against the sibling TemplateBuilder.Editor (ASP.NET Core) package —
findings and fixes documented in that repo's docs/qa/2026-09-04-subject-field-and-backport-qa-*.md.
Two of the four findings applied here; the other two (a native "leave site?" dialog on Create, and an
"Imported" activity-chip color question) did not reproduce in this package and required no change.
- Fixed: inserting a field token (drag-and-drop from the field palette, the palette's Insert
button, or the toolbar's "Insert Field" dropdown) silently dropped its
<span class="tb-field">wrapper, leaving plain, unstyled{{ model.Field }}text in the editor instead of the intended chip. The wrapper'sspan/class/contenteditableattributes were correctly whitelisted for paste-sanitization, but SunEditor'sinsertHTML(html)runs a separate internal HTML cleaner on programmatic inserts unless told not to (insertHTML(html, notCleaningData)) — that second cleaner isn't governed by the paste whitelist at all, and it stripped the span outright. All three call sites now passtrueas the second argument; each is safe to do so because the inserted string is entirely this package's own construction (an escaped field name in a fixed shape), never raw pasted or user-typed HTML. - Fixed: creating a new template silently dropped the selected Source SQL View. Picking a
view on the Create page's "Source SQL View" dropdown before first save had no effect — the
Create JSON payload never included
sourceView, and even if it had,CreateTemplateJsonnever read it from the request, unlikeSaveVersionwhich already buildsSourceViewand itsSourceViewSnapshotcorrectly. Landing on Edit afterward always showed "— None —", requiring a manual reselection (and a full page reload) to fix. The Create payload now sendssourceView, and the controller now setsTemplate.SourceView/SourceViewSnapshoton creation, mirroringSaveVersion's existing pattern.
v1.3.13
Fixed: the code-view textarea (
</>toolbar button) could still render narrow inside a real host page, even though it was already fixed and verified working in this package's own sample host in v1.3.9/v1.3.10. Those earlier fixes setwidth: 100% !importanton the code-view element but never contestedmax-width— a host page's own generic textarea styling (a common Bootstrap/form-control pattern, e.g.textarea { max-width: 500px }) still caps it, sincewidthandmax-widthare independent properties:width: 100%correctly won the cascade with nothing to override it, butmax-widthwas never set by this package at all, so the host's rule applied uncontested regardless of!importantor specificity on either side. The WYSIWYG editor was never affected — it's acontenteditablediv/iframe, not a<textarea>, so a generic hosttextarea { ... }rule can't reach it; only the code view uses a real<textarea>element. Now setsmax-width: 100% !importantalongside the existingwidth: 100% !important. Found and confirmed against a real consumer host app: identified the exact conflicting rule (Site.css:33: textarea { max-width: 500px }) via the browser's Computed styles panel, verified overriding it fixed the rendering, then verified the shipped fix resolves it with no manual override needed.Clarified:
ApplyMigrations = falsemeans exactly what it says — no automatic DDL, ever. Not a code change, but worth calling out plainly after a real upgrade question: with this option set (the DBA-managed database mode), the package installs no database initializer and never attempts schema changes on your behalf, including this and every future release that adds a column. If you're onApplyMigrations = false, you must apply each release's schema delta by hand — see the shippedcontent/Scripts/TemplateBuilder.schema.*.sqlfor the full script, or pull just the newALTER TABLEstatement(s) at the end of it for an incremental upgrade. The v1.3.11 entry below already flagged this for the Subject column specifically; this note is here so it's not missed on a quick skim.
v1.3.12
- Fixed: the activity drawer's open/close button could get stuck — clicking it after
saving at least one version would open the drawer, then it would immediately vanish again
before the slide-in animation finished, and the tab itself would stay visually shifted into
its "open" position from then on, no longer responding correctly to clicks.
loadTimeline()(which refreshes the activity count/list) attached the tab's click listener, the close button's click listener, and the Escape-key listener every time it ran — and it runs more than once: once at page load, and again after every successful Save Version (a v1.3.10 fix). Each extra run stacked another click listener on the same elements with nothing removing the old ones, so after one Save Version a single click fired the open/close toggle twice in the same tick — open, then immediately close — before the open animation had a chance to complete. The close call's synchronous class removal raced the open call'srequestAnimationFrame-delayed class addition, so the "open" CSS class ended up applied after the removal and stuck there. The listener wiring now runs once, at script load, instead of insideloadTimeline(). Found via manual testing, reproduced by reading the code, and verified live by confirming the fix is present in the served embedded JS resource.
v1.3.11
- Added: an optional Subject field for Email-type templates.
TemplateVersion.Subjectis a new nullable, per-version column (nvarchar(500)) alongsideBody— it round-trips through Create, Edit, Save Version, and Restore exactly like Body does, and Restore carries the restored version's Subject forward together with its Body. The editor's properties panel (right side of the editor, under Template Name) shows a Subject input for Email-type templates only; switching a template's type away from Email hides the row (the value is still saved, just not shown, per the "keep the data, just hide the field" design decision) rather than clearing it, so no data is silently lost if you switch a template's type back and forth. - Added:
ITemplateEngine.RenderEmailAsync(int templateId, object model, CancellationToken ct), returning a newRenderedEmail { Subject, Body }— renders both the Subject and Body of a template's current/last-active version through the same Scriban pipeline asRenderAsync, so{{ model.* }}placeholders in a Subject line resolve exactly like they do in the body.RenderEmailAsynconly knows about persisted versions, so it isn't used by the Preview endpoint (which renders unsaved editor content submitted from the browser). The editor's own Preview modal renders the subject the same way — throughRenderBodyAsync— so{{ model.* }}placeholders resolve identically in both. - Behavior change: Template Promotion's export schema bumped from v2 to v3 to carry Subject
through export/import (
TemplateExportVersion.Subject) and the bulk-zip manifest. Promotion files exported by a package version prior to 1.3.11 (schema v2) are now rejected on import — there is no automatic upgrade path for old exports, matching this package's existing behavior of rejecting schema v1 files. If you have existing v2 promotion exports you still need, export them again from a template on this version (or later) before importing into a 1.3.11+ instance. - Database schema note for hand-applied SQL: if you manage your schema with raw SQL instead
of EF6 migrations, do not run the whole
Scripts/TemplateBuilder.schema.1.3.11.sqlfile against a database that already has the 1.3.10 schema — it's a full from-scratch script and itsCREATE TABLEstatements will fail against tables that already exist. Apply only the delta for theAddSubjectToTemplateVersionsmigration instead:ALTER TABLE [dbo].[TemplateVersions] ADD [Subject] [nvarchar](500).
v1.3.10
Fixed: creating a brand-new template silently discarded whatever content you typed.
POST /Templates/Createbuilt the newTemplaterow from name/type/description only and never created aTemplateVersion— the body sent in the request was read into the model and then never used. Landing on the Edit page for a freshly-created template always showed an empty canvas and "v 0" (no version exists yet, sinceBodythere falls back toCurrentVersion?.Body ?? string.Empty). Create now also publishes an initial version from the submitted body in the same request, so a new template always starts at v1 with its actual content. Verified live end-to-end: created a template with real body text against a real database, confirmed the Edit page shows that exact content and "v 1", not empty/"v 0".Fixed: clicking "Create Template" showed a spurious "leaving site, unsaved changes" browser prompt even though the create had just succeeded.
createTemplate()navigates to the new template's Edit page viawindow.location.hrefon success, but never cleared the dirty flag left over from typing into the editor first — so the browser's own unsaved-changes guard fired for a save that had already completed. Now clears it before navigating, matching the pattern already used by Save Version.Fixed: the code-view textarea (
</>toolbar button) still rendered at roughly half width even after the v1.3.9 code-view width fix. That fix targeted a.se-code-wrapper-nested element that turned out not to match the real DOM — devtools confirmed the actual element is<textarea class="se-wrapper-inner se-wrapper-code">, both classes on one node, not nested under a separate wrapper. Added a selector that matches it directly.Fixed: the activity drawer's counter (
#tb-activity-count) and entry list didn't update after Save Version —loadTimeline()was only ever called once, at page load, so a save that added a new audit entry left the drawer showing stale data until a manual page refresh. Save Version now calls it again on success. Verified live that the audit entry is committed and queryable immediately (no race) by the time the save's response returns, so the refreshed count is always accurate.
v1.3.9
- Fixed: calling
ITemplateEngine.RenderAsync/RenderByNameAsync/RenderBodyAsyncdirectly with a plain C# object (anonymous type or POCO) rendered the template with every placeholder empty, e.g.RenderAsync(id, new { ContextSentence = "...", Note = "..." })against a template containing{{ model.ContextSentence }}produced no output for that field, with no error.ScriptObject.Import(model)uses Scriban's default member renamer, which converts C# member names to snake_case (its Liquid-compatibility convention) —ContextSentencebecomescontext_sentenceinternally, so a template written with the member's real C# name (the convention this package's own README, field palette, and every example use — e.g.{{ model.DueDate }}) silently failed to resolve. This specifically affects direct API consumers passing a real object; the package's own Preview feature was never exposed to it because it always builds aDictionary<string, object>from parsed JSON, which bypasses the reflection-based renamer entirely — same as why the existing Scriban reference tests never caught this. BothImport()calls now pass an identity renamer (member => member.Name) to keep member names verbatim. Added a regression test reproducing the exact reported shape (multi-word PascalCase members) alongside the existing single-lowercase-word cases.
v1.3.8
Fixed: the field palette showed "Failed to load columns" even though the
/Templates/Api/Views/{view}/ColumnsAPI returned 200 with real data.GetViewColumnspassed theSqlColumnInfoDTO straight toJson(...), whose defaultJavaScriptSerializerpreserves C# PascalCase property names verbatim (Name,DataType,MaxLength,IsNullable) instead of camelCasing them — every other endpoint in the controller works around this by projecting to a lowercase anonymous object, but this one didn't. The frontend readc.name/c.dataType, gotundefined, andescapeHtml(undefined)threw inside the generictry/catch, surfacing as a misleading "failed to load" message. Now projects to{ name, dataType, maxLength, isNullable }like every other JSON response. Verified live: the raw API response casing was confirmed broken, then confirmed fixed, against a real SQL view. (Also fixed the same latent casing bug — currently unobserved but same root cause — in the Snippets create/update/restore responses, which usednew { id, created.Name }shorthand.)Fixed: inline (
data:URI) images and CSS background images inside snippets/templates were silently stripped on Save/Preview — e.g. a header snippet's background banner disappeared entirely, and a footer snippet's logo rendered as a broken image. Every Save/Preview runs the server-sideHtmlSanitizer(XSS protection) after Scriban rendering; its defaultAllowedSchemesis{http, https}only, so both<img src="data:...">andbackground-image: url(data:...)— the standard way HTML email inlines images, since many email clients block remote images by default — were removed with no error surfaced anywhere. AddeddatatoAllowedSchemes. Verified live against the real sanitizer pipeline (not just unit tests): a header with adata:background and a footer with adata:logo now both survive Preview intact; added regression tests locking in the behavior alongside the existing XSS tests (javascript:URLs,<script>, event handlers) to confirm nothing else was loosened.Fixed: sample/preview JSON typed into the Preview modal could be silently lost. Unlike template body edits, typing sample JSON never marked the page "dirty," so the existing unsaved-changes warning never covered it — and restoring an old version (from Version History or the Compare view) reloads the page immediately after a successful restore. If you'd typed or generated custom sample data but hadn't separately clicked the small "Save to template" button, that reload discarded it without warning, and Preview fell back to generic placeholder data. Restoring a version now persists any unsaved sample data first if it exists and differs from what's already saved; typing/generating sample data also now trips the same unsaved-changes browser warning as body edits (tracked separately from body-dirty, since Save Version was clearing the warning without ever touching sample data). Verified the underlying persistence is per-template, not per-version, via a live create → save sample data → save new version → confirm-still-present round trip.
Added: a "← Templates" link back to the template list on the Template Health, Audit Log, and Edit pages — previously the only way back was the browser's Back button. Placed as a breadcrumb-style link above the page header on Health/Audit, and next to the theme toggle in the CANVAS panel toolbar on Edit (which has no single top-level header to attach it to). Reuses the existing
.btn-ghostbutton style and--text-muted/--accenthover pattern, no new visual language introduced.Fixed: the code view (
</>toolbar button) rendered its textarea at a stale, too-narrow width inside the CANVAS panel — the same class of bug as the main WYSIWYG editor's width freeze fixed in v1.3.7, but on SunEditor's separate code-view element (.se-wrapper-code), which had aheight: 100% !importantoverride but no matchingwidthoverride. Addedwidth: 100% !importantalongside it.
v1.3.7
- Fixed: SunEditor rendered at a stale, permanently-too-narrow width inside the CANVAS panel
(
/Templates/{id}/Edit), leaving a blank gap before the PROPERTIES panel and forcing toolbar buttons onto an extra row.SUNEDITOR.create()had nowidthoption, so SunEditor fell back to measuring its container synchronously at creation time and froze whatever it measured (e.g.498px) as a permanent inline style — before the 3-panel layout had necessarily finished settling, and with no re-measurement afterward. Addedwidth: '100%'alongside the existingheight: '100%', telling SunEditor to use a responsive percentage instead of a frozen pixel value. Verified in a live browser against/Templates/{id}/Edit: full-width toolbar, no gap, no visual regressions.
v1.3.6
- Fixed:
options.LayoutPathstill rendered/Templatesblank (or a 500) on real IIS/.NET Framework hosting. v1.3.5 added the shell-view indirection soViewResult.MasterNamecould point at a package-embedded shell instead of directly at your physical layout — necessary because RazorGenerator's view engine only resolvesMasterNamethrough its own embedded view mappings. That part worked. What was still missing: the shell view sets its ownLayoutto your configured path in code, and ASP.NET WebPages resolves that specific kind of assignment through a completely different registry (System.Web.WebPages.VirtualPathFactoryManager) than the one MVC uses forMasterName. RazorGenerator'sPrecompiledMvcEngineis capable of answering that registry's lookups (it implementsIVirtualPathFactory) but nothing was ever registering it there — only intoViewEngines.Engines, which the WebPages layout resolver never consults. Every editor page threwHttpException: The layout page "..." could not be found(or rendered blank if the exception was swallowed by a custom error page) wheneverLayoutPathwas set.RegisterTemplateBuilderEditor()now also callsVirtualPathFactoryManager.RegisterVirtualPathFactory(...)for the package's own assembly whenLayoutPathis configured — no consumer wiring changes needed. Verified end-to-end against a real IIS Express + SQL Server host with aLayoutPath-configured, non-ambient-_ViewStartapp (this scenario had previously only been verified by code review and unit tests, not a live request — the earlier verification was blocked by a Mono/xsp4 incompatibility in that environment).
v1.3.5
- IIS static asset handler — new
TemplateBuilderAssetHandler(IHttpHandler) that IIS can invoke directly via a<handlers>web.config entry, so editor CSS/JS is served without requiringrunAllManagedModulesForAllRequests="true". Theinstall.ps1script now automatically adds the handler entry to yourWeb.configduring install (in the Package Manager Console). The_setupdiagnostic page verifies embedded resources and warns about IIS pipeline blocking. options.LayoutPath— opt-inTemplateBuilderEditorOptions.LayoutPathfor host apps that assignLayoutper-view instead of relying on_ViewStart.cshtml. Set it to a layout path (e.g."~/Views/Shared/_Layout.cshtml") and every editor page renders inside your site chrome. Leave null (default) for unchanged behavior. Must be an app-relative virtual path starting with~/(with the.cshtmlextension) —RegisterTemplateBuilderEditor()now throws at startup on a malformed value instead of silently rendering/Templatesblank, and the_setupdiagnostic page verifies the configured path resolves to a real file.
v1.3.3
- Smoother Activity drawer — opening no longer "shakes" the page: focus moves into
the drawer with
preventScroll(the browser never scrolls to reveal the still- off-screen close button), and the drawer tab now animates viatransformon the compositor instead ofright(no per-frame layout). Both drawer and tab share one easing curve, so open feels as smooth as close.prefers-reduced-motionstill disables all transitions. - Refined activity timeline — each event now shows the actor as an initials avatar, the action as a color-coded chip (published = green, deleted/rejected = red, restore/toggle/duplicate = amber, everything else = indigo), the actor name, a relative timestamp (absolute on hover), and comments as a subtle quoted block.
- Audit page — the Activity chart card and the Filters card are now exactly the same height.
v1.3.2
- DBA-managed databases — new
TemplateBuilderEditorOptions.ApplyMigrations(defaulttrue). Set it tofalseand the package never runs migrations and never attempts DDL — for SQL logins with DML-only rights. The package now ships a generated schema script (content/Scripts/TemplateBuilder.schema.<version>.sql) that a DBA runs once to provision the database (all tables, indexes, and migration-history rows; generated from the migration chain so it cannot drift). Upgrades ship a new versioned script file.
v1.3.1
- Fixed: no more "No connection string named 'TemplateBuilderDbContext' could be found"
for consumers who configure only
options.ConnectionString(e.g. a name likeTemplateDb). The runtime migrations pipeline now runs against your explicit connection string; the namedTemplateBuilderDbContextentry is no longer required in your Web.config. (It remains needed only if you use the Package Manager ConsoleUpdate-Database/Add-Migrationtooling.)
v1.3.0
- New
TemplateBuilderEditorOptions.ActorResolver— supply your own author identity (claims, user id, username) stored asCreatedBy/ auditActor. Falls back toUser.Identity.Name, then"anonymous". Legacy null values now display "anonymous". - Template version history now stamps
CreatedByon every save (previously never populated); existing versions are not backfilled.
v1.2.0
- Two-state save model — every version is either Draft or Active (
TemplateVersion.IsActive). The editor's footer now has two buttons: Save Draft (saves a Draft version) and Save Version (saves an Active version), and the version history shows an Active/Draft badge on every version. - Workflow removed (breaking) — the draft → review → approve → publish state machine is gone:
SubmitForReview,Approve,Reject,CancelReview,Publish, and the server-side draft/auto-save endpoints have been deleted. A draft is now simply a version saved withisActive:false. - Promotion format schemaVersion 2 (breaking) — exports carry per-version
isActiveflags andschemaVersion: 2; imports accept onlyschemaVersion: 2(v1 export files are rejected). - Render API contract —
RenderAsync/RenderByNameAsyncnow serve the last Active version and throw typed exceptions instead of silently rendering a draft:TemplateNotFoundException,TemplateInactiveException(template switched off),NoActiveVersionException(no Active version yet).
v1.1.0
{{ model.X }}template syntax — templates can reference model fields through themodelprefix ({{ model.RecipientName }}), matching what the field palette inserts; bothmodel.*and top-level access render.- Create accepts HTML template bodies — Create is now a JSON endpoint (like Save Version), so rich HTML bodies pass request validation cleanly on Windows IIS and mono/xsp4 hosts.
- Server-side sample-data generation — Generate sample JSON from the selected SQL view, from
{{ model.X }}tokens in the template, or both; save it with the template for one-click preview. - Field palette search, used-field markers, and model badges — find fields fast and see which are already referenced in the canvas.
- Scriban syntax reference panel — a searchable quick-reference for Scriban statements,
modelaccess, and expected output. mailto:links preserved in preview — the sanitizer now allows themailtoscheme, so email links in your templates survive preview/compare rendering.- Real server error messages in the UI — duplicate-name and validation errors are shown verbatim instead of a generic failure message.
- Antiforgery dependency fix —
Microsoft.AspNet.WebHelpersis now declared explicitly so[ValidateJsonAntiForgeryToken]works in packages.config solutions.
v1.0.0
- Initial release — full template management UI for ASP.NET MVC 5 / .NET Framework 4.8: create/edit, version history, restore, side-by-side compare, live preview with auto-generated sample JSON, reusable snippets, find & replace, auto-save drafts, dark/light themes.
- Feature parity with the ASP.NET Core
TemplateBuilder.EditorUI, ported to apackages.config-friendly, non-SDK-style hosting environment. - Precompiled Razor views via RazorGenerator — the package ships zero
.cshtmlfiles. - CSS isolation — all editor styles scoped to
#tb-editor-host, safe alongside Bootstrap 3.3.7 / jQuery / IgniteUI host pages. - Header-based anti-forgery for JSON endpoints (
ValidateJsonAntiForgeryTokenAttribute) — the community-standard MVC 5 pattern, working on Windows IIS and mono. - Scriban rendering with
model.*syntax and output sanitization via HtmlSanitizer. - EF6 Code-First migrations applied automatically on startup.
tools/install.ps1ships binding-redirect guidance for packages.config consumers.
Updating
Update-Package TemplateBuilder.Editor.Mvc5
EF migrations are bundled — schema changes apply automatically on next startup. Hard-refresh (Ctrl+Shift+R) to pick up the new CSS/JS assets.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET Framework | net48 is compatible. net481 was computed. |
-
.NETFramework 4.8
- EntityFramework (>= 6.5.1)
- HtmlSanitizer (>= 9.2.995)
- Microsoft.AspNet.Mvc (>= 5.3.0)
- Microsoft.AspNet.WebHelpers (>= 3.3.0)
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.Options (>= 8.0.2)
- Newtonsoft.Json (>= 13.0.3)
- RazorGenerator.Mvc (>= 2.4.9)
- Scriban (>= 7.2.6)
- System.Text.Json (>= 10.0.8)
- Unity (>= 5.11.10)
- Unity.Mvc5 (>= 1.4.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.3.15 | 127 | 9/7/2026 |
| 1.3.14 | 104 | 9/5/2026 |
| 1.3.13 | 110 | 9/3/2026 |
| 1.3.12 | 98 | 9/3/2026 |
| 1.3.10 | 112 | 9/2/2026 |
| 1.3.9 | 110 | 8/28/2026 |
| 1.3.8 | 98 | 8/28/2026 |
| 1.3.7 | 109 | 8/27/2026 |
| 1.3.6 | 106 | 8/27/2026 |
| 1.3.5 | 104 | 8/27/2026 |
| 1.3.4 | 105 | 8/27/2026 |
| 1.3.3 | 114 | 8/22/2026 |
| 1.3.2 | 111 | 8/22/2026 |
| 1.3.1 | 114 | 8/21/2026 |
| 1.3.0 | 115 | 8/21/2026 |
| 1.2.0 | 112 | 8/21/2026 |
| 1.1.0 | 112 | 8/19/2026 |
| 1.0.0 | 108 | 8/18/2026 |