The HCTI API can take complete HTML and CSS, or a URL, and turn it into an image. Since 2020, customers have also been able to save reusable HTML/CSS templates with Handlebars variables and render them with much smaller JSON payloads. Their code supplies the changing data while the template owns the design.
Our introduction to the Template Editor covers why we added a visual way to build those reusable assets and how it fits alongside the existing API. For this post, one distinction matters most:
This is a template editor, not an image maker.
A saved template may be rendered thousands of times later with data the person designing it has never seen. The author needs to define which parts can change, which keys an API caller should provide, and what happens when one is missing. The editor needs to preview that contract, not only the current image.
Handlebars already handled text and HTML well, including helpers such as if, unless, with and each. A block-based template also has dynamic image URLs, colors, sizes and other values that are real types inside the editor. Storing {{brand.primary}} as the background color would reduce a validated color property to a string and leave the renderer to sort it out later.
We kept Handlebars for content and added structured variable bindings beside it. Both feed a shared inventory and accept one ordinary JSON payload through the existing API. Getting there required typed wrappers, explicit missing-value behavior, local preview state and, regrettably, a small reflection-based tour through Handlebars.Net's compiler internals.
Related Reading
The architecture overview maps the surrounding editor. Separate posts show how these values enter the Blazor property UI and become generated CSS.
Templatable Values
For text and HTML, the existing model still works. If someone is writing content, they should be able to use normal Handlebars and move on.
But block templates needed more than content replacement. A product card might need a dynamic image URL, price, sale color, badge label and rating. A social post might need different text, but also different background colors or avatar images. A report image might need numbers, colors, units and layout choices.
Most importantly, these still had to feel like API variables, not editor internals. A customer should send JSON like this:
{
"product": {
"imageUrl": "https://example.com/shoe.png",
"price": "$89"
},
"brand": {
"primary": "#6147ff"
}
}
They should not need to know that brand.primary happens to drive a block background, a border, a piece of text, or some generated CSS path inside the editor. Our address system is for us. Template keys are for them.
That left us with a fairly specific set of rules:
- Variables needed to work for values outside of content, including colors, URLs, sizes, opacity, font sizes and many more.
- Live preview had to stay local. Editing sample JSON should update the canvas without asking the server to render.
- Missing values needed explicit behavior. Some values are required, some should fall back to the designed value, and some should be skipped if the payload does not include them.
- Handlebars needed to remain the language for content, without forcing every typed property to become a string containing
{{...}}. - The same variable should be reusable across compatible properties, while incompatible reuse needed to be caught before rendering.
- The public API still needed to be plain, user-defined JSON.
We represent those properties with a generic templatable wrapper. A color or size can use its literal value or connect to a variable, while still carrying a real typed fallback value. TemplateKey names the variable and TemplateRequirementMode defines what happens when it is absent. That metadata lives beside the value instead of being extracted from a Handlebars string.
The shape is roughly:
// Shortened for readability.
public sealed partial class TemplateProperty_Templatable<T>
where T : IParsable<T>
{
public T Value { get; set; } = default!;
public string? TemplateKey { get; set; }
public TemplateRequirementMode RequirementMode { get; set; }
}
A templatable color is still a color. A templatable size is still a size. A templatable image URL is still an image URL. The editor can validate, preview and serialize each one, then generate CSS or markup without turning everything into a string too early.
Requirement Modes
Missing variables are not all errors. Sometimes a template cannot render correctly without a value. Sometimes the author has already designed a perfectly good fallback. In other cases, a property should simply be left out when the caller does not provide it.
Requirement modes let the template author make that decision while building the template:
| Mode | When the key is missing |
|---|---|
Required |
Reject the render request with a useful validation error. |
Fallback |
Use the typed value saved with the property. |
Ignore |
Skip the value, declaration or optional content that depended on it. |
Consider a product card. Its product image may be Required, because there is no useful card without it. A sale badge color can use Fallback, preserving the color chosen by the designer unless the API caller overrides it. An optional strike-through price can be Ignore, allowing the same template to render products that are not on sale.
This behavior is part of the template's API contract. The caller does not need to know which block owns a property or reproduce a list of editor-side rules. Before rendering, the variable inventory can validate that required keys exist and that values are compatible with the expected kind. A required color must actually parse as a color. A value used by each needs to be a collection, and one used by with needs to be an object.
The same key may be used in several places. When that happens, the inventory resolves the combined requirement conservatively. Required wins over Fallback, which wins over Ignore. One optional use cannot make a value optional when another property genuinely requires it.
Fallback values live with the typed property instead of becoming a Handlebars trick. If a color falls back, it falls back to a real TemplateColor. The renderer is not left trying to parse a half-rendered string or guess what the author meant. Handlebars-only variables can also have persisted requirement rules and JSON fallback values, so both variable sources participate in the same contract.
Editing the Contract
The variable control is available beside any property input that supports templating. Our shared input base gives TemplateVariablePopover the property's generated base address. From there, the popover derives the addresses for TemplateKey and RequirementMode, so it does not need a custom callback for color, size, opacity, image URL, etc.
Our razor component is mostly a small form for the three modes and the customer-defined key:
@* Rendered by the shared input base. *@
<TemplateVariablePopover PropertyBaseAddress="@PathId" />
@* Inside TemplateVariablePopover.razor: *@
<Popover IsOpen="@TemplateEditorOpen"
IsOpenChanged="HandleTemplateEditorOpenChangedAsync">
<AnchorContent Context="popover">
<button type="button" @onclick="popover.ToggleAsync">
<Icon Name="@iconName" Size="14" />
</button>
</AnchorContent>
<ChildContent Context="_">
<SwitchInput Value="@TemplateDraftEnabled"
ValueChanged="HandleTemplateDraftEnabledChangedAsync"
Label="Apply Variable?" />
@if (TemplateDraftEnabled)
{
<label>
<input type="radio"
name="@TemplateModeGroupName"
checked="@(TemplateDraftMode == TemplateRequirementMode.Required)"
@onchange="@(() => SetTemplateDraftMode(TemplateRequirementMode.Required))" />
Required
</label>
@* Ignore and Fallback use the same radio pattern. *@
<input value="@TemplateDraftKey"
placeholder="profile.name"
@oninput="HandleTemplateDraftKeyInput"
@onkeydown="HandleTemplateDraftKeyDownAsync" />
}
<button type="button" @onclick="CloseTemplateEditorSaveAsync">
Save
</button>
</ChildContent>
</Popover>
The same popover works for multi-selection. One transaction updates every compatible selected node, and the generated tracking paths handle the actual routing. Before committing, the collector checks the proposed key against the existing inventory, catching collisions such as customer versus customer.name while the author can still fix them.
Building the Variable Inventory
TemplateVariableUsageCollector is the meeting place for structured properties and Handlebars content. It maintains one entry per normalized key, then records every property or content location using that key. Those usages carry their expected kind, requirement mode, node ID and enough address information to get back to the source.
The collector does not reflect over the template model. Source generation adds ListTemplatedProperties(...) to every generated block and nested value that can contain a templatable property. Those methods recursively visit base classes, nested proxies and collections, while attaching the known TemplateValueKind for each property. The collector only has to consume the generated traversal:
// Shortened for readability.
public void Collect<T>(T item)
where T : IHasCollectUsage, ITemplateNode
{
item.ListTemplatedProperties(
only_include_templated: true,
exclude_empty_keys: true,
(property, keyAndMode) =>
{
var (templateKey, requirementMode) = keyAndMode;
AddPropertyUsage(
templateKey!,
new PropertyVariableUsage(
item.Id,
property.PropertyAddress,
property.TemplateKeyAddress,
property.RequirementModeAddress,
requirementMode,
property.ValueKind));
});
if (item is ITemplateVariableUsageEmitter contentSource)
{
contentSource.CollectTemplateVariableUsages(this);
}
}
That last interface is how blocks with free-form content join the same process. Text and HTML blocks implement ITemplateVariableUsageEmitter, discover variables inside their content, and add those usages to the collector. Normal typed properties need no manual registration.
When the inventory is rebuilt, the collector visits the canvas and every block, merges compatible kinds and requirement modes, restores persisted Handlebars rules, and produces diagnostics for collisions or incompatible reuse. The variable sidebar, popover validation, local preview and API validation all read from that result instead of maintaining their own lists.
Discovering Handlebars Variables
Typed properties now have a direct route into the collector. Handlebars content is messier. Handlebars is built to render templates, not to hand you a neat variable inventory. A regex can find simple tokens, but it cannot reliably explain what they mean:
{{customer.name}}
{{#if customer.isActive}}Active{{/if}}
{{#with customer}}{{address.city}}{{/with}}
{{#each lineItems}}{{name}}{{/each}}
Those examples include a scalar path, a conditional value, a scoped object and values relative to a collection item. Helpers may contain arguments or subexpressions, blocks may have inverted branches, and @root changes how a path should be resolved. We needed to walk the same expression structure that Handlebars itself understands.
Handlebars.Net has the tokenizer and expression builder needed to do that, but it does not expose them as a supported variable-discovery API. Regrettably, we use reflection to reach those compiler pieces and inspect several expression types. The shortened setup looks like this:
// Shortened for readability.
[DynamicDependency(
DynamicallyAccessedMemberTypes.PublicMethods,
"HandlebarsDotNet.Compiler.Lexer.Tokenizer",
"Handlebars")]
private static readonly Type TokenizerType =
Type.GetType(
"HandlebarsDotNet.Compiler.Lexer.Tokenizer, Handlebars",
throwOnError: true)!;
private static readonly Type ExpressionBuilderType =
Type.GetType(
"HandlebarsDotNet.Compiler.ExpressionBuilder, Handlebars",
throwOnError: true)!;
private static readonly MethodInfo TokenizeMethod =
TokenizerType.GetMethod("Tokenize", BindingFlags.Public | BindingFlags.Static)!;
private static readonly MethodInfo ConvertTokensToExpressionsMethod =
ExpressionBuilderType.GetMethod(
"ConvertTokensToExpressions",
BindingFlags.Public | BindingFlags.Static)!;
var tokenResult = TokenizeMethod.Invoke(null, [reader]);
var tokens = ((IEnumerable)tokenResult!).Cast<object>().ToArray();
var expressionResult = ConvertTokensToExpressionsMethod.Invoke(
null,
[tokens, compiledConfiguration]);
foreach (var expression in ((IEnumerable)expressionResult!).Cast<Expression>())
{
WalkExpression(expression, scope, collector);
}
The actual setup also retrieves Handlebars.Net's compiled configuration and caches PropertyInfo instances for path, statement, iterator, helper and subexpression nodes. The walker handles normal paths, if, unless, with, each, helper arguments, inversions and root-qualified references. Each discovered use records the block ID and changed-property address where it came from, along with its helper kind and expected value kind.
This reflection is isolated to the discovery engine. We are not reflecting over the template model or using it for every property update. The type and member handles are cached, and discovery runs when the variable inventory needs to be rebuilt. It is still a fragile dependency on another library's internals. Handlebars.Net upgrades need scrutiny, and the DynamicDependency annotations are necessary to keep the WASM trimmer from removing members that only reflection can see.
Writing our own almost-Handlebars parser would avoid reflection while creating a different problem: it would need to stay compatible with the real renderer forever. Using the library's own compiler representation was the smaller compromise, even if it is not one of the prettier parts of the system.
The merged inventory prevents key collisions like customer and customer.name. This is not a limitation of JSON itself; it follows from treating dots as paths into the customer's payload. In that path system, customer cannot be both a final value and an object containing name. The inventory also tracks whether a key came from a typed property, Handlebars content, or both. It keeps a kind hint so compatible sharing works and suspicious sharing gets called out. Reusing brand.primary for a text color and a border color is useful. Reusing it for a color and an image URL is probably a mistake.
Local Variable Preview
Preview is where this gets awkward. The editor keeps a sample payload, builds an effective variable context from it, applies fallbacks when needed, and tracks a variable preview revision separately from normal block changes. Changing one JSON value is not an edit to a block, but it might affect text content, generated CSS and validation messages across several blocks. It needs to notify the right things without becoming undo history or forcing unrelated block components to rebuild.
The previewer accepts the same nested JSON shape as the public API. We flatten and validate it against the variable inventory, then make it available to both Handlebars content and typed property resolution. CSS-producing components watch the variable preview revision, while text and HTML blocks render against the same effective context. No preview values are written back into the saved template.
This matters for requirement modes too. A template author can remove a key from the preview payload and immediately see whether the design falls back, omits the property or reports a required-value error. The behavior they test in the editor is the behavior the API will apply later.
Resolving Template Values from JSON
We did not want customers reshaping their data just to call our API. If their application already has a customer object with an address, or an order with a collection of line items, they should be able to send that object largely as it exists:
{
"customer": {
"name": "Sam Rivera",
"address": {
"city": "Philadelphia"
}
},
"lineItems": [
{
"name": "Annual plan",
"price": "$99"
}
]
}
Template keys use a small, predictable path syntax. customer.name resolves the name, customer.address.city resolves the nested city, and lineItems[0].price can address a specific array item. Handlebars can still use the original objects and collections with with and each.
We intentionally stopped short of implementing full JSONPath (for now). There are no filters, recursive descent operators, wildcards or arbitrary expressions in a structured property key. Those features may be useful later, but they complicate static discovery, collision checks, requirement modes and type validation. Concrete property segments and numeric array indexes cover the common API shapes without turning a variable key into another programming language.
At resolution time, TemplateVariableCtx keeps two views of the payload. The nested JsonObject remains available to Handlebars. A flattened dictionary gives typed properties a direct lookup by their normalized key:
| Nested JSON value | Flattened key |
|---|---|
| Customer name | customer.name |
| Customer city | customer.address.city |
| First line-item object | lineItems[0] |
| First line-item price | lineItems[0].price |
There are two fallback paths here. Persisted Handlebars fallbacks are JSON values, so we clone the posted payload and insert any missing Handlebars fallback values at their nested paths before flattening. Structured property fallbacks are different: they remain on the typed property and are selected later if its key cannot be resolved from the flattened payload.
TemplateVariableCtx builds the effective JSON, flattens it, and validates required rules against the effective object:
public static TemplateVariableCtx Create(
JsonObject? postedJson,
TemplateVariableUsageCollector collector,
bool leaveEmptyPlaceholders)
{
var effectiveJson = BuildEffectiveTemplateJson(postedJson, collector);
var flatValues = TemplateVariablePath.Flatten(effectiveJson);
var context = new TemplateVariableCtx(
flatValues,
effectiveJson,
leaveEmptyPlaceholders);
context.ValidateDiscoveredRules(effectiveJson, collector);
return context;
}
Flattening also normalizes scalar JSON values into useful .NET values such as bool, long, double and string. Typed property resolution can look up one key, parse or convert the result into its known type, and apply the property's requirement mode when the key is absent. It does not need to walk the JSON tree every time CSS is generated.
Callers can reuse their existing objects, while the editor gets a stable set of paths it can discover, validate and preview. They may still choose to send a purpose-built payload, but the API does not require one just because the template references nested data.
Conclusion
Structured bindings let us extend variables beyond text without turning the template model into strings or asking customers to understand our internal property addresses. Colors, sizes and image URLs keep their real types. Handlebars remains the content language it has been since our original template API.
Requirement modes make missing data part of the template contract, and the shared inventory catches incompatible reuse and drives local preview. The Handlebars discovery engine includes a reflection-based compromise that I would rather not have, but it keeps discovery aligned with the parser we actually render with instead of a regex or a second, almost-compatible implementation.
For the customer, none of that changes the payload. They define keys that make sense for their application and send normal JSON. The extra structure stays on our side, where it can make the editor safer and the finished template more predictable.
Template Editor Engineering Series
| Post | Description |
|---|---|
| Building a Visual Template Editor in Blazor WASM | A high-level tour of the decisions, architecture, and shared systems behind the editor. |
| Building Consistent Blazor UX with Source Generators | Turning typed C# models into consistent property controls, previews, and rendered blocks with source generators. |
| Undo, Redo, and Live Preview - No Diffs Needed | Compact property addresses, generated tracking facades, transactions, undo/redo, and preview state. |
| Why Our Blazor App Still Uses TypeScript | A practical split between Blazor-owned application state and TypeScript-owned browser behavior. |
| Beyond Text Replacement: Building Typed Template Variables | Keeping Handlebars for content while extending variables to colors, sizes, images, and other typed properties. |
| Building a System to Generate Complex CSS | Generating deterministic CSS for nested blocks, panels, previews, and both browser and server renderers. |
| Simplifying Complexity: MemoryPack to the Rescue | MemoryPack storage, read-time schema repair, and typed MagicOnion communication across browser and server. |
| Productionizing Our Blazor WebAssembly Editor | Runtime performance, trimming, loading experience, and browser-backed testing for a production Blazor WASM editor. |
