RazorConsole uses a Virtual DOM (VDOM) to translate Razor components into Spectre.Console renderables. The translation process is powered by translators - pluggable components that convert VDOM nodes into specific Spectre.Console visual elements. This document explains the translator architecture and how to extend RazorConsole with custom translators.
The IVdomElementTranslator interface defines the contract for translating VDOM nodes to Spectre.Console renderables:
public interface IVdomElementTranslator
{
/// <summary>
/// Gets the priority of this translator. Lower values are processed first.
/// </summary>
int Priority { get; }
/// <summary>
/// Attempts to translate a VNode to an IRenderable.
/// </summary>
bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable);
}Key aspects:
- Priority: Determines the order in which translators are tried. Lower values = higher priority (processed first).
- TryTranslate: Returns
trueand setsrenderableif the translator can handle the node; otherwise returnsfalse.
Provides access to recursive translation for child nodes:
public sealed class TranslationContext
{
/// <summary>
/// Attempts to translate a VNode to an IRenderable.
/// </summary>
public bool TryTranslate(VNode node, out IRenderable? renderable);
}Use context.TryTranslate() to recursively translate child nodes within your custom translator.
The main orchestrator that:
- Accepts a prioritized list of translators via dependency injection
- Iterates through translators in priority order
- Returns the first successful translation
- Provides utility methods for common translation tasks
Razor Component → ConsoleRenderer → VNode Tree → VdomSpectreTranslator
↓
Iterate translators by priority
↓
First successful translator wins
↓
Spectre.Console IRenderable
RazorConsole includes 20 built-in translators with priorities from 10 to 1000:
| Priority | Translator | Handles | Spectre Type |
|---|---|---|---|
| 10 | TextElementTranslator | <span data-text="true"> |
Markup |
| 20 | HtmlInlineTextElementTranslator | <strong>, <em>, <code> |
Markup |
| 30 | ParagraphElementTranslator | <p> |
Markup |
| 40 | SpacerElementTranslator | <div data-spacer="true"> |
Padder |
| 50 | NewlineElementTranslator | <br> |
Text |
| 60 | SpinnerElementTranslator | <div class="spinner"> |
Spinner |
| 70-80 | Button translators | <button> |
Button renderables |
| 90 | SyntaxHighlighterElementTranslator | <div class="syntax-highlighter"> |
SyntaxRenderable |
| 100-190 | Layout translators | Panels, rows, columns, grids, padding, alignment | Layout renderables |
| 1000 | FailToRenderElementTranslator | Fallback for unhandled nodes | Diagnostic markup |
Create a class that implements the interface:
using RazorConsole.Core.Rendering.Vdom;
using RazorConsole.Core.Vdom;
using Spectre.Console;
using Spectre.Console.Rendering;
public sealed class OverflowElementTranslator : IVdomElementTranslator
{
// Define priority (1-1000+). Lower = higher priority.
// Use 1-9 to run before all built-ins
// Use 10-190 to interleave with specific built-ins
// Use 200-999 to run after most built-ins but before fallback
// Use 1000+ to run after all built-ins
public int Priority => 85;
public bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable)
{
renderable = null;
// 1. Check if this node is what we handle
if (node.Kind != VNodeKind.Element)
{
return false; // Not an element, skip
}
if (!string.Equals(node.TagName, "div", StringComparison.OrdinalIgnoreCase))
{
return false; // Not a div, skip
}
// Check for our custom attribute
if (!node.Attributes.TryGetValue("data-overflow", out var overflowType))
{
return false; // No overflow attribute, skip
}
// 2. Extract configuration from attributes
var width = VdomSpectreTranslator.TryParsePositiveInt(
VdomSpectreTranslator.GetAttribute(node, "data-width"),
out var w) ? w : 80;
// 3. Translate child nodes
if (!VdomSpectreTranslator.TryConvertChildrenToRenderables(
node.Children, context, out var children))
{
return false; // Children couldn't be translated
}
var content = VdomSpectreTranslator.ComposeChildContent(children);
// 4. Create the Spectre.Console renderable
renderable = overflowType?.ToLowerInvariant() switch
{
"ellipsis" => new Padder(content).Overflow(Overflow.Ellipsis),
"crop" => new Padder(content).Overflow(Overflow.Crop),
"fold" => new Padder(content).Overflow(Overflow.Fold),
_ => content
};
return true; // Successfully translated
}
}Register your custom translator in your application's service configuration:
using Microsoft.Extensions.DependencyInjection;
using RazorConsole.Core;
using RazorConsole.Core.Vdom;
var app = AppHost.Create<MyComponent>(builder =>
{
// Register by type (translator will be instantiated via DI)
builder.Services.AddVdomTranslator<OverflowElementTranslator>();
// Or register by instance
builder.Services.AddVdomTranslator(new OverflowElementTranslator());
// Or register with factory (useful for DI dependencies)
builder.Services.AddVdomTranslator(sp =>
new OverflowElementTranslator(/* inject services here */));
});
await app.RunAsync();Create Razor components that emit the VDOM structure your translator expects:
@namespace MyApp.Components
<div data-overflow="ellipsis" data-width="40">
This is a very long line of text that will be truncated with an ellipsis
when it exceeds the specified width of 40 characters.
</div>
<div data-overflow="fold">
<Markup>[bold]This text will fold/wrap[/] when it's too long
for the available space in the console window.
</Markup>
</div>VdomSpectreTranslator provides static utility methods to simplify translator implementation:
// Get attribute value
string? value = VdomSpectreTranslator.GetAttribute(node, "data-style");
// Check for CSS class
bool hasClass = VdomSpectreTranslator.HasClass(node, "my-class");
// Collect all inner text recursively
string? text = VdomSpectreTranslator.CollectInnerText(node);// Parse boolean attribute
if (VdomSpectreTranslator.TryGetBoolAttribute(node, "data-enabled", out bool enabled))
{
// Use enabled value
}
// Parse integer with fallback
int count = VdomSpectreTranslator.TryGetIntAttribute(node, "data-count", fallback: 10);
// Parse positive integer
if (VdomSpectreTranslator.TryParsePositiveInt(rawValue, out int result))
{
// Use result
}
// Parse optional positive integer (returns null if invalid)
int? maybeWidth = VdomSpectreTranslator.ParseOptionalPositiveInt(rawValue);
// Parse padding (CSS-style: "1", "1,2", "1,2,3", or "1,2,3,4")
if (VdomSpectreTranslator.TryParsePadding(rawValue, out Padding padding))
{
// Use padding
}// Parse horizontal alignment
var hAlign = VdomSpectreTranslator.ParseHorizontalAlignment(value);
// Returns: Left, Center, or Right
// Parse vertical alignment
var vAlign = VdomSpectreTranslator.ParseVerticalAlignment(value);
// Returns: Top, Middle, or Bottom// Translate child nodes to renderables
if (VdomSpectreTranslator.TryConvertChildrenToRenderables(
node.Children, context, out List<IRenderable> renderables))
{
// Success - use renderables list
}
// Compose multiple renderables into one
IRenderable composed = VdomSpectreTranslator.ComposeChildContent(renderables);
// Returns single item as-is, multiple items as Rows, empty as empty MarkupChoose priorities strategically:
- 1-9: Ultra-high priority for overriding built-in behavior
- 10-190: Interleave with specific built-in translators
- Use priority just below a built-in to handle more specific cases
- Use priority just above a built-in to intercept before it
- 200-999: General custom translators
- 1000+: Fallback handlers that run after all built-ins
Return false immediately if the node doesn't match your criteria:
// Good: Early returns
if (node.Kind != VNodeKind.Element) return false;
if (node.TagName != "div") return false;
if (!node.Attributes.ContainsKey("data-custom")) return false;
// Bad: Nested conditions
if (node.Kind == VNodeKind.Element)
{
if (node.TagName == "div")
{
if (node.Attributes.ContainsKey("data-custom"))
{
// Logic here
}
}
}VDOM attributes and tag names use case-insensitive comparison:
// Good: Case-insensitive
if (string.Equals(node.TagName, "div", StringComparison.OrdinalIgnoreCase))
// Bad: Case-sensitive (may miss valid matches)
if (node.TagName == "div")Always use TryConvertChildrenToRenderables for recursive translation:
// Good: Recursive translation via context
if (!VdomSpectreTranslator.TryConvertChildrenToRenderables(
node.Children, context, out var children))
{
return false; // Failed to translate children
}
// Bad: Accessing raw children (loses translation)
var rawChildren = node.Children; // VNode children, not renderables!Validate attributes before use and provide sensible defaults:
// Good: Validation with defaults
var width = VdomSpectreTranslator.TryParsePositiveInt(
VdomSpectreTranslator.GetAttribute(node, "data-width"),
out var w) ? w : 80; // Default to 80 if invalid or missing
// Bad: Assuming valid input
var width = int.Parse(node.Attributes["data-width"]); // May throw!Create new renderable instances; don't mutate existing ones:
// Good: Create new instances
var panel = new Panel(content)
.Expand()
.BorderColor(Color.Blue);
// Bad: Reusing renderables
var shared = new Panel(content);
shared.Expand(); // Modifies shared stateTranslators should be stateless or use immutable state:
// Good: Stateless translator
public sealed class MyTranslator : IVdomElementTranslator
{
public int Priority => 100;
public bool TryTranslate(VNode node, TranslationContext context,
out IRenderable? renderable)
{
// No instance fields modified
}
}
// Bad: Mutable state
private int _counter; // Shared across translations - NOT thread-safe!Translators can receive dependencies via constructor injection:
public sealed class DatabaseStyleTranslator : IVdomElementTranslator
{
private readonly IStyleProvider _styleProvider;
public DatabaseStyleTranslator(IStyleProvider styleProvider)
{
_styleProvider = styleProvider ?? throw new ArgumentNullException(nameof(styleProvider));
}
public int Priority => 95;
public bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable)
{
renderable = null;
if (!node.Attributes.TryGetValue("data-style-id", out var styleId))
{
return false;
}
// Fetch style from database via injected service
var style = _styleProvider.GetStyle(styleId);
// Create renderable with style
// ...
return true;
}
}
// Registration
builder.Services.AddSingleton<IStyleProvider, MyStyleProvider>();
builder.Services.AddVdomTranslator<DatabaseStyleTranslator>();Handle multiple scenarios within one translator:
public sealed class AlertTranslator : IVdomElementTranslator
{
public int Priority => 105;
public bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable)
{
renderable = null;
if (!VdomSpectreTranslator.HasClass(node, "alert"))
{
return false;
}
// Translate children once
if (!VdomSpectreTranslator.TryConvertChildrenToRenderables(
node.Children, context, out var children))
{
return false;
}
var content = VdomSpectreTranslator.ComposeChildContent(children);
// Different styles based on alert type
if (VdomSpectreTranslator.HasClass(node, "alert-danger"))
{
renderable = new Panel(content)
.BorderColor(Color.Red)
.Header("[red]⚠ Error[/]");
}
else if (VdomSpectreTranslator.HasClass(node, "alert-success"))
{
renderable = new Panel(content)
.BorderColor(Color.Green)
.Header("[green]✓ Success[/]");
}
else if (VdomSpectreTranslator.HasClass(node, "alert-warning"))
{
renderable = new Panel(content)
.BorderColor(Color.Yellow)
.Header("[yellow]⚡ Warning[/]");
}
else
{
// Default alert style
renderable = new Panel(content)
.BorderColor(Color.Blue)
.Header("[blue]ℹ Info[/]");
}
return true;
}
}Build sophisticated layouts:
public sealed class CardTranslator : IVdomElementTranslator
{
public int Priority => 110;
public bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable)
{
renderable = null;
if (!VdomSpectreTranslator.HasClass(node, "card"))
{
return false;
}
// Find specific child sections
var headerNode = node.Children.FirstOrDefault(c =>
VdomSpectreTranslator.HasClass(c, "card-header"));
var bodyNode = node.Children.FirstOrDefault(c =>
VdomSpectreTranslator.HasClass(c, "card-body"));
var footerNode = node.Children.FirstOrDefault(c =>
VdomSpectreTranslator.HasClass(c, "card-footer"));
var parts = new List<IRenderable>();
// Translate each section
if (headerNode != null && context.TryTranslate(headerNode, out var header))
{
parts.Add(header);
parts.Add(new Rule().RuleStyle(Style.Parse("grey")));
}
if (bodyNode != null && context.TryTranslate(bodyNode, out var body))
{
parts.Add(body);
}
if (footerNode != null && context.TryTranslate(footerNode, out var footer))
{
parts.Add(new Rule().RuleStyle(Style.Parse("grey")));
parts.Add(footer);
}
if (parts.Count == 0)
{
return false;
}
// Wrap in panel
renderable = new Panel(new Rows(parts))
.BorderColor(Color.Grey);
return true;
}
}Problem: Your translator's TryTranslate method is never invoked.
Solutions:
- Verify the translator is registered:
services.AddVdomTranslator<MyTranslator>() - Check priority - a higher-priority translator may be handling the node first
- Ensure the VDOM structure matches your expectations (use debugger to inspect
node)
Problem: TryTranslate returns false when it should succeed.
Solutions:
- Add logging to each conditional check to identify which fails
- Verify attribute names match exactly (case-insensitive but must be spelled correctly)
- Check that
node.KindisVNodeKind.Elementfor element nodes - Ensure child translation succeeds before trying to use children
Problem: Your translator succeeds but the wrong visual appears.
Solutions:
- Verify another translator isn't handling the same node (check priorities)
- Ensure you're setting
out renderableto the correct Spectre type - Check that child translation is using
context.TryTranslate()for recursion
Problem: Your translator runs at the wrong time relative to built-ins.
Solutions:
- Review the built-in translator priority table above
- Choose a priority just above or below the conflicting translator
- Consider using 1-9 for overrides, 200-999 for new functionality
Test translators in isolation:
using Xunit;
using RazorConsole.Core.Rendering.Vdom;
using RazorConsole.Core.Vdom;
public class OverflowTranslatorTests
{
[Fact]
public void HandlesOverflowDiv()
{
// Arrange
var translator = new OverflowElementTranslator();
var node = VNode.CreateElement("div");
node.SetAttribute("data-overflow", "ellipsis");
node.AddChild(VNode.CreateText("Long text"));
var mainTranslator = new VdomSpectreTranslator();
var context = new TranslationContext(mainTranslator);
// Act
var success = translator.TryTranslate(node, context, out var renderable);
// Assert
Assert.True(success);
Assert.NotNull(renderable);
}
[Fact]
public void IgnoresNonOverflowDiv()
{
// Arrange
var translator = new OverflowElementTranslator();
var node = VNode.CreateElement("div");
// No data-overflow attribute
var mainTranslator = new VdomSpectreTranslator();
var context = new TranslationContext(mainTranslator);
// Act
var success = translator.TryTranslate(node, context, out var renderable);
// Assert
Assert.False(success);
Assert.Null(renderable);
}
}Test within the full RazorConsole pipeline:
using Microsoft.Extensions.DependencyInjection;
using RazorConsole.Core;
[Fact]
public async Task OverflowTranslatorIntegration()
{
// Arrange
var app = AppHost.Create<TestComponent>(builder =>
{
builder.Services.AddVdomTranslator<OverflowElementTranslator>();
});
// Act
var snapshot = await RenderComponentAsync<TestComponent>(app.Services);
// Assert
// Verify the rendered output contains expected overflow handling
Assert.Contains("...", snapshot.ToString());
}The IVdomElementTranslator extensibility model provides a powerful way to extend RazorConsole with custom Spectre.Console renderables:
- Implement
IVdomElementTranslatorwith your custom logic - Define a priority to control when your translator runs
- Register the translator using
AddVdomTranslator<T>() - Use utility methods from
VdomSpectreTranslatorfor common tasks - Test thoroughly with unit and integration tests
This architecture enables rich console UIs while maintaining clean separation between Razor components (structure) and Spectre.Console renderables (presentation).