orchardcore-shapes
Skill for working with shapes in Orchard Core's display management system. Covers shape creation, shape templates (Liquid and Razor), shape alternates, shape wrappers, shape metadata, ad-hoc shapes, I
Files2
View on GitHubSKILL.mdsrc/CrestApps.AgentSkills/orchardcore/orchardcore-shapes/SKILL.md
GitHubOrchard Core Shapes - Prompt Templates
Working with Shapes
You are an Orchard Core expert. Generate shape templates, shape providers, and shape-related code for Orchard Core.
Guidelines
- Shapes are the fundamental rendering unit in Orchard Core's display management.
- Every piece of visible output is rendered through a shape.
- Shapes have a type name (e.g.,
Content,Widget,TextField), metadata, and properties. - Shape templates can be written in Liquid (
.liquid) or Razor (.cshtml). Both use the same file naming rules. - Use canonical names (
_and__) in C#,placement.json, and shape metadata. Use dots and hyphens only in template file names. - For
DisplayDriver<T>.Edit()flows, remember the root editor shape for the model type as well as any nested field/editor shapes. IfBuildEditorAsync()is used forTableauExportPipeline, Orchard will resolve a rootTableauExportPipeline_Editshape in addition to shapes likeTableauExportPipelineFields_Edit. - For ALL admin editor Razor shapes (
*.Edit.cshtmlincluding site settings editors), shape templates should use theocat-*(Orchard Core Admin Theme) CSS classes for consistent field layout:ocat-wrapperfor the outer row,ocat-labelfor labels,ocat-endfor the input column,ocat-end-offsetfor checkbox-only layouts/headings/buttons,ocat-limited-wrapper+ocat-limitedfor narrower inputs. - The former
@Orchard.GetWrapperClasses(),@Orchard.GetLabelClasses(),@Orchard.GetEndClasses()helper methods andTheAdminThemeOptionsclass have been removed. Use the staticocat-*CSS classes directly. Do NOT use these classes in frontend views or Admin Menu node editing. IShapeFactorycreates shapes dynamically from code;IDisplayHelperrenders them inside a view;IShapeHtmlRendererrenders them to a string outside of a view.IShapeTableProvidercustomizes shape behavior (alternates, wrappers, bindings).IDisplayManager<T>orchestrates building and rendering shapes for content.- Always seal classes.
How Shapes Work
Shapes are objects that carry data and metadata for rendering. The rendering pipeline:
- A display driver returns an
IDisplayResult(usually aShapeResult). - Placement rules are resolved first; a hidden shape is never created.
- The display manager builds the shape using
IShapeFactoryand adds it to the selected zone. - When rendered,
Displayinghandlers run, then the binding is resolved by checking alternates from highest to lowest priority, then the shape type and its__fallbacks. - The binding (Razor, Liquid, or a
[Shape]method) renders the shape, then wrappers render around itsChildContent.
If no binding can be resolved, rendering throws an InvalidOperationException naming the missing shape type and active theme.
Shape Metadata
Every shape has a Metadata property (ShapeMetadata) with:
Type— The logical shape type used for binding resolution (not the CLR type).DisplayType— The display context (e.g.,Detail,Summary,Edit).Alternates— Ordered alternate binding names; the last added has the highest priority.Wrappers— Shape types rendered around this shape; the first added is the innermost.Name/Differentiator— Identify the shape inside its parent and among reused shape types.Position— The position within a zone.Prefix— HTML field prefix used while rendering editors.TabGrouping,CardGrouping,ColumnGrouping— Editor grouping set by placement.PlacementSource— Where the placement rule came from.ChildContent— The rendered output so far (set by the binding, cache, or wrappers). Do not use it as input data.
Template File Names
| Canonical binding name | Razor or Liquid file name |
|---|---|
Content |
Content.cshtml / Content.liquid |
Content__BlogPost |
Content-BlogPost.cshtml / .liquid |
Content_Summary__BlogPost |
Content-BlogPost.Summary.cshtml / .liquid |
Content_Summary |
Content.Summary.cshtml / .liquid |
Widget__HeroBanner |
Widget-HeroBanner.cshtml / .liquid |
Fields_Common_Text__FirstName |
Views/Fields/Common.Text-FirstName.cshtml / .liquid |
Rules:
-becomes__(breaking separator, creates fallback levels);.becomes_.- When the final dot appears after the final hyphen, the suffix is a display type inserted before the first
__:Content-BlogPost.Summary→Content_Summary__BlogPost. - Templates are harvested from
Views,Views/Items,Views/Parts,Views/Fields, andViews/Elements; the last three addParts_,Fields_, orElements_prefixes. - A file named
Content__BlogPost__Summary.liquiddoes not match the summary alternate; useContent-BlogPost.Summary.liquid.
Creating Shape Templates
Liquid Shape Template (Views/Content.liquid)
<article>
<header>
{{ Model.Header | shape_render }}
</header>
{{ Model.Content | shape_render }}
<footer>
{{ Model.Footer | shape_render }}
</footer>
</article>
Razor Shape Template (Views/Content.cshtml)
<article>
<header>
@await DisplayAsync(Model.Header)
</header>
@await DisplayAsync(Model.Content)
<footer>
@await DisplayAsync(Model.Footer)
</footer>
</article>
Root Editor Wrapper Template (Views/TableauExportPipeline_Edit.cshtml)
When a DisplayDriver<T> editor is built through IDisplayManager<T>.BuildEditorAsync() or UpdateEditorAsync(), Orchard also resolves a root editor shape for the model type. That root template usually just renders the editor zones populated by nested shapes:
@await DisplayAsync(Model.Content)
Without this wrapper, Orchard throws an InvalidOperationException saying the root shape type (for example TableauExportPipeline_Edit) was not found.
Shape Alternates
Alternates let more specific templates override generic ones. Alternates added later win; do not add the base type itself as an alternate.
For a Content shape displaying a BlogPost content type in Summary display type, the resolver checks (most specific first):
| Alternate | File Name |
|---|---|
Content_Summary__BlogPost |
Content-BlogPost.Summary.cshtml / .liquid |
Content__BlogPost |
Content-BlogPost.cshtml / .liquid |
Content_Summary |
Content.Summary.cshtml / .liquid |
Content (base) |
Content.cshtml / .liquid |
Other content alternates include Content__Alias__[Alias], Content__Slug__[Slug], and their display-type variants. See orchardcore-templates for the full alternate catalog.
Shape types containing __ also fall back by removing the last segment: Car__Toyota__Hybrid → Car__Toyota → Car.
Rendering Shapes in Liquid Templates
Basic Shape Rendering
<!-- Create and render a shape by type, with properties -->
{% shape "ShapeName", prop1: "value1", prop2: "value2" %}
<!-- Create, populate, then render -->
{% assign car = "Car" | shape_new %}
{% shape_add_properties car brand: "Toyota", color: "Blue" %}
{{ car | shape_render }}
<!-- Render a shape object or a zone of the current shape -->
{{ Model.Content | shape_render }}
{{ Model.Header | shape_render }}
<!-- Render a content item with a display type -->
{{ Model.ContentItem | shape_build_display: "Summary" | shape_render }}
In a Liquid layout, render layout zones with {% render_section "Header", required: false %} and the body with {% render_body %}. The {% zone "Footer" %}...{% endzone %} block adds markup to a layout zone; it does not render it.
Rendering Content Part Shapes in Liquid
<!-- Render all shapes in the Content zone -->
{{ Model.Content | shape_render }}
<!-- Render a specific part's shape -->
{{ Model.Content.TitlePart | shape_render }}
{{ Model.Content.HtmlBodyPart | shape_render }}
<!-- Remove one shape by name before rendering the zone -->
{% shape_remove_item Model.Content "HtmlBodyPart" %}
{{ Model.Content | shape_render }}
Iterating Over Shapes in a Zone
{% for item in Model.Content %}
{{ item | shape_render }}
{% endfor %}
Conditional Shape Rendering in Liquid
{% if Model.Content.TitlePart %}
<h1>{{ Model.Content.TitlePart | shape_render }}</h1>
{% endif %}
{% if Model.Footer %}
<footer>{{ Model.Footer | shape_render }}</footer>
{% endif %}
Rendering Shapes in Razor Templates
@* Render a zone of the current shape *@
@await DisplayAsync(Model.Content)
@await DisplayAsync(Model.Header)
@* Render a specific part shape *@
@await DisplayAsync(Model.Content.TitlePart)
@* Create and render an ad-hoc shape *@
@{
var shape = await New.MyCustomShape(Property1: "value1");
}
@await DisplayAsync(shape)
@* Tag helper form *@
<shape type="MyCustomShape" prop-property1="value1" />
Use DisplayAsAsync to render an existing shape as another logical type. It clears alternates by default and changes Metadata.Type on the same instance:
@await DisplayAsAsync(Model, "CompactCar")
@await DisplayAsAsync(Model, "CompactCar", clearAlternates: false)
Creating Shapes from Code
Using IShapeFactory
using OrchardCore.DisplayManagement;
public sealed class MyService
{
private readonly IShapeFactory _shapeFactory;
private readonly IShapeHtmlRenderer _shapeHtmlRenderer;
public MyService(IShapeFactory shapeFactory, IShapeHtmlRenderer shapeHtmlRenderer)
{
_shapeFactory = shapeFactory;
_shapeHtmlRenderer = shapeHtmlRenderer;
}
public async Task<IShape> CreateCustomShapeAsync()
{
// Dynamic shape with copied properties, rendered by "MyCustomShape" templates.
return await _shapeFactory.CreateAsync("MyCustomShape", Arguments.From(new
{
Title = "Hello",
Description = "World",
}));
}
public async Task<IShape> CreateTypedShapeAsync()
{
// Strongly typed shape: the model must be a non-sealed class (a proxy derives from it).
return await _shapeFactory.CreateAsync<CarViewModel>("Car", car =>
{
car.Brand = "Toyota";
car.Color = "Blue";
});
}
// Outside of a view (API endpoint, background-rendered fragment, JSON response).
public async Task<string> RenderCustomShapeAsync(CancellationToken cancellationToken)
=> await _shapeHtmlRenderer.RenderAsync(await CreateCustomShapeAsync(), cancellationToken);
}
Inside a view, render with @await DisplayAsync(shape). Never call IDisplayHelper from an endpoint without a view; use IShapeHtmlRenderer instead (RenderAsync or RenderZoneAsync(shape, "Content")).
Using Initialize in Display Drivers
Display drivers create shapes using Initialize<TModel>:
public sealed class MyPartDisplayDriver : ContentPartDisplayDriver<MyPart>
{
public override IDisplayResult Display(MyPart part, BuildPartDisplayContext context)
{
return Initialize<MyPartViewModel, MyPart>("MyPart", static (model, part) =>
{
model.Text = part.Text;
model.ContentItem = part.ContentItem;
}, part)
.Location("Detail", "Content:5")
.Location("Summary", "Content:5");
}
}
See orchardcore-display-management for every ShapeResult helper (Initialize, Dynamic, Copy, View, Factory, Combine) and the 4.0 typed-state and ValueTask overloads.
Root + Nested Editor Shape Pattern
If your driver returns nested editor shapes like Initialize<MyViewModel>("MyTypeFields_Edit", ...), also add the root editor template for the model type used by BuildEditorAsync():
public sealed class MyTypeDisplayDriver : DisplayDriver<MyType>
{
public override IDisplayResult Edit(MyType model, BuildEditorContext context)
{
return Initialize<MyTypeViewModel>("MyTypeFields_Edit", viewModel =>
{
viewModel.Name = model.Name;
})
.Location("Content:1");
}
}
@* Views/MyType_Edit.cshtml *@
@await DisplayAsync(Model.Content)
@* Views/MyTypeFields.Edit.cshtml *@
@model MyTypeViewModel
<input asp-for="Name" class="form-control" />
This split keeps the model-level wrapper and the field editor template aligned with Orchard's shape resolution rules.
Combining Multiple Shapes
public override IDisplayResult Display(MyPart part, BuildPartDisplayContext context)
{
return Combine(
Initialize<MyPartViewModel>("MyPart", model =>
{
model.Text = part.Text;
}).Location("Detail", "Content:5"),
Initialize<MyPartSummaryViewModel>("MyPart_Summary", model =>
{
model.Summary = part.Summary;
}).Location("Summary", "Content:5"),
Dynamic("MyPart_Actions")
.Location("SummaryAdmin", "Actions:5")
);
}
Ad-Hoc (Dynamic) Shapes
Create shapes without a specific view model:
public override IDisplayResult Display(MyPart part, BuildPartDisplayContext context)
{
return Dynamic("MyPart_Badge", shape =>
{
shape.Text = part.BadgeText;
shape.CssClass = part.IsActive ? "active" : "inactive";
})
.Location("Detail", "Meta:5");
}
Shape Table Providers
IShapeTableProvider lets you customize shape behavior globally. The shape table is built once per tenant and theme, so only register behavior (handlers, bindings) there; never resolve request data or register resources inside DiscoverAsync.
Adding Alternates
using OrchardCore.DisplayManagement.Descriptors;
public sealed class CarShapeTableProvider : IShapeTableProvider
{
public ValueTask DiscoverAsync(ShapeTableBuilder builder)
{
builder.Describe("Car")
.OnDisplaying(context =>
{
if (context.Shape is CarViewModel car)
{
// Least specific first: the last added alternate wins.
context.Shape.Metadata.Alternates.Add($"Car_Color__{car.Color}");
context.Shape.Metadata.Alternates.Add($"Car_Brand__{car.Brand}");
}
});
return ValueTask.CompletedTask;
}
}
Values used in binding names should be controlled identifiers, not arbitrary user input.
Adding Wrappers
public sealed class WidgetShapeTableProvider : IShapeTableProvider
{
public ValueTask DiscoverAsync(ShapeTableBuilder builder)
{
builder.Describe("Widget")
.OnDisplaying(context =>
{
// Wrap all widgets with the Widget_CardWrapper template.
context.Shape.Metadata.Wrappers.Add("Widget_CardWrapper");
});
return ValueTask.CompletedTask;
}
}
Registering a Shape Table Provider
public sealed class Startup : StartupBase
{
public override void ConfigureServices(IServiceCollection services)
{
services.AddShapeTableProvider<CarShapeTableProvider>();
services.AddShapeTableProvider<WidgetShapeTableProvider>();
}
}
Shape Wrapper Templates
A wrapper receives the same shape; Model.Metadata.ChildContent holds the output rendered so far. Never call DisplayAsync(Model) or shape_render on Model inside a wrapper, which re-renders the shape recursively.
Liquid Wrapper (Views/Widget.CardWrapper.liquid)
<div class="card widget-{{ Model.ContentItem.ContentType | downcase }}">
<div class="card-body">
{{ Model.Metadata.ChildContent }}
</div>
</div>
Razor Wrapper (Views/Widget.CardWrapper.cshtml)
<div class="card">
<div class="card-body">
@Model.Metadata.ChildContent
</div>
</div>
Wrappers can also be added from placement.json ("wrappers": [ "Widget_CardWrapper" ]) or the <add-wrapper> metadata tag helper.
Code Bindings with [Shape]
using Microsoft.AspNetCore.Html;
using OrchardCore.DisplayManagement;
using OrchardCore.DisplayManagement.Descriptors;
public sealed class CarShapes : IShapeAttributeProvider
{
[Shape("Car")]
public IHtmlContent Car(IShape Shape)
{
var car = (CarViewModel)Shape;
var tagBuilder = Shape.GetTagBuilder("div");
tagBuilder.InnerHtml.Append($"A {car.Color} {car.Brand} car.");
return tagBuilder;
}
}
Register with services.AddShapeAttributes<CarShapes>();.
Layouts, Zones, and Item Positions
ILayoutAccessor.GetLayoutAsync()returns the request'sLayoutshape; its zones areZoneshapes created on demand.- Content shapes built by display managers hold
ContentZonezones, which build tab, card, and column grouping shapes when children carry grouping metadata. await shape.AddAsync(child, "5")adds a positioned item. Positions sort numerically and hierarchically:start < before < 0 (empty) < 1 < 1.1 < 2 < 10 < after < free text < end. Usestart/endwhen an item must be truly first or last, including relative to free-text positions such asPrefixPosition()values.
var layout = await _layoutAccessor.GetLayoutAsync();
var menu = await _shapeFactory.CreateAsync("Menu");
await layout.Zones["Header"].AddAsync(menu, "5");
Common Shape Types Reference
| Shape Type | Description | Template |
|---|---|---|
Content |
Main content item display | Content.liquid |
Content_Edit |
Content item editor | Content.Edit.liquid |
Widget |
Widget display wrapper | Widget.liquid |
Zone |
Layout zone container | Zone.liquid |
MenuItem |
Menu item rendering | MenuItem.liquid |
MenuItemLink |
Menu item with link | MenuItemLink.liquid |
NavigationItem |
Navigation item | NavigationItem.liquid |
List |
List part container | List.liquid |
PagerSlim |
Pagination controls | PagerSlim.liquid |
Pager |
Full pagination | Pager.liquid |
TextField |
Text field display | TextField.liquid |
HtmlField |
HTML field display | HtmlField.liquid |
BooleanField |
Boolean field display | BooleanField.liquid |
DateTimeField |
DateTime field display | DateTimeField.liquid |
NumericField |
Numeric field display | NumericField.liquid |
MediaField |
Media field display | MediaField.liquid |
ContentPickerField |
Content picker display | ContentPickerField.liquid |
TaxonomyField |
Taxonomy field display | TaxonomyField.liquid |
LinkField |
Link field display | LinkField.liquid |
Overriding Built-In Shape Templates
Override Content Display (Views/Content.liquid)
<article class="content-item content-item-{{ Model.ContentItem.ContentType | downcase }}">
{% if Model.Header %}
<header>{{ Model.Header | shape_render }}</header>
{% endif %}
{% if Model.Meta %}
<div class="meta">{{ Model.Meta | shape_render }}</div>
{% endif %}
{{ Model.Content | shape_render }}
{% if Model.Footer %}
<footer>{{ Model.Footer | shape_render }}</footer>
{% endif %}
</article>
Override Content for Specific Type (Views/Content-BlogPost.liquid)
<article class="blog-post">
<header>
<h1>{{ Model.ContentItem.DisplayText }}</h1>
<time datetime="{{ Model.ContentItem.PublishedUtc | date: '%Y-%m-%d' }}">
{{ Model.ContentItem.PublishedUtc | date: "%B %d, %Y" }}
</time>
</header>
{{ Model.Content | shape_render }}
{% if Model.Footer %}
<footer class="post-footer">
{{ Model.Footer | shape_render }}
</footer>
{% endif %}
</article>
Override Widget (Views/Widget.liquid)
<div class="widget widget-{{ Model.ContentItem.ContentType | downcase }}">
<div class="widget-body">
{{ Model.Content | shape_render }}
</div>
</div>
Override Summary Display (Views/Content.Summary.liquid)
<article class="content-summary">
<h3>
<a href="{{ Model.ContentItem | display_url }}">
{{ Model.ContentItem.DisplayText }}
</a>
</h3>
{% if Model.Meta %}
<div class="meta">{{ Model.Meta | shape_render }}</div>
{% endif %}
{{ Model.Content | shape_render }}
</article>
Shape Debug Information
Enable Write shape debug information in the tenant's Settings → Debugging editor. Orchard Core applies that setting to ShapeRenderingOptions through IOptionsMonitor, so it takes effect without a shell release.
When enabled, each rendered shape is wrapped with comments like:
<!--shape-start type:Content bindings:Content__BlogPost => Views/Content-BlogPost.cshtml (razor) -->
...
<!--shape-end type:Content-->
The comment shows the shape type and the binding that won. To find slow shapes rather than template names, collect the OrchardCore.DisplayManagement activity source and meter (see orchardcore-display-management) or use orchardcore-mini-profiler.
When an override is not selected, check: the shape's Metadata.Type, DisplayType, and ordered Alternates; canonical name vs file name form; that the template is under a harvested Views folder; whether a later alternate wins; whether placement changed the shape type (which clears alternates and wrappers); and whether the active theme or startup project overrides the module binding.