The Page/Widget DSL (M2) -- Content Presentation
This DSL maps Diem's page composition model, enhanced with Symfony CMF's dynamic routing and Wagtail's page tree model. Pages are runtime entities stored in the database -- content editors create and arrange them at runtime, not at compile time.
The Page Entity Model
Page, Layout, Zone, and WidgetInstance are EF Core entities shipped with the CMF. They are not DSL attributes -- they are the runtime infrastructure:
// These are runtime entities, not DSL attributes.
// They ship with Cmf.Pages.Lib.
public class Page
{
public PageId Id { get; set; }
public PageId? ParentId { get; set; }
public string Slug { get; set; }
public string Title { get; set; }
public string MaterializedPath { get; set; } // "/catalog/electronics"
public LayoutId LayoutId { get; set; }
public Layout Layout { get; set; }
public string? BoundAggregateType { get; set; } // "Product"
public string? BoundAggregateId { get; set; } // serialized ID
public PublishStatus Status { get; set; }
public ICollection<Page> Children { get; set; }
}
public class Layout
{
public LayoutId Id { get; set; }
public string Name { get; set; } // "TwoColumn", "FullWidth"
public ICollection<Zone> Zones { get; set; }
}
public class Zone
{
public ZoneId Id { get; set; }
public LayoutId LayoutId { get; set; }
public string AreaName { get; set; } // "top", "left", "content", "right"
public int Order { get; set; }
public string? CssClass { get; set; }
public ICollection<WidgetInstance> Widgets { get; set; }
}
public class WidgetInstance
{
public WidgetInstanceId Id { get; set; }
public ZoneId ZoneId { get; set; }
public string WidgetType { get; set; } // "ProductList", "Hero", "Menu"
public int Order { get; set; }
public string ConfigJson { get; set; } // serialized widget config
}
public enum PublishStatus { Draft, Published, Archived }// These are runtime entities, not DSL attributes.
// They ship with Cmf.Pages.Lib.
public class Page
{
public PageId Id { get; set; }
public PageId? ParentId { get; set; }
public string Slug { get; set; }
public string Title { get; set; }
public string MaterializedPath { get; set; } // "/catalog/electronics"
public LayoutId LayoutId { get; set; }
public Layout Layout { get; set; }
public string? BoundAggregateType { get; set; } // "Product"
public string? BoundAggregateId { get; set; } // serialized ID
public PublishStatus Status { get; set; }
public ICollection<Page> Children { get; set; }
}
public class Layout
{
public LayoutId Id { get; set; }
public string Name { get; set; } // "TwoColumn", "FullWidth"
public ICollection<Zone> Zones { get; set; }
}
public class Zone
{
public ZoneId Id { get; set; }
public LayoutId LayoutId { get; set; }
public string AreaName { get; set; } // "top", "left", "content", "right"
public int Order { get; set; }
public string? CssClass { get; set; }
public ICollection<WidgetInstance> Widgets { get; set; }
}
public class WidgetInstance
{
public WidgetInstanceId Id { get; set; }
public ZoneId ZoneId { get; set; }
public string WidgetType { get; set; } // "ProductList", "Hero", "Menu"
public int Order { get; set; }
public string ConfigJson { get; set; } // serialized widget config
}
public enum PublishStatus { Draft, Published, Archived }Pages form a hierarchical tree stored in the database. The tree defines URL structure:
/ (Home)
├── /catalog (Catalog)
│ ├── /catalog/electronics (Category: Electronics)
│ │ └── /catalog/electronics/laptop-x1 (Product: Laptop X1)
│ └── /catalog/clothing (Category: Clothing)
├── /cart (Cart)
└── /about (About)/ (Home)
├── /catalog (Catalog)
│ ├── /catalog/electronics (Category: Electronics)
│ │ └── /catalog/electronics/laptop-x1 (Product: Laptop X1)
│ └── /catalog/clothing (Category: Clothing)
├── /cart (Cart)
└── /about (About)The dynamic routing middleware resolves URLs from the page tree at runtime:
// Simplified routing pipeline
public class PageRouterMiddleware
{
public async Task InvokeAsync(HttpContext context, IPageTreeResolver resolver)
{
var path = context.Request.Path.Value;
var page = await resolver.ResolveAsync(path);
if (page is null)
{
await _next(context);
return;
}
// Page found — load layout, zones, widgets
var layout = await _layoutStore.GetAsync(page.LayoutId);
var pageContext = new PageRenderContext(page, layout);
context.Features.Set(pageContext);
await _next(context);
}
}// Simplified routing pipeline
public class PageRouterMiddleware
{
public async Task InvokeAsync(HttpContext context, IPageTreeResolver resolver)
{
var path = context.Request.Path.Value;
var page = await resolver.ResolveAsync(path);
if (page is null)
{
await _next(context);
return;
}
// Page found — load layout, zones, widgets
var layout = await _layoutStore.GetAsync(page.LayoutId);
var pageContext = new PageRenderContext(page, layout);
context.Features.Set(pageContext);
await _next(context);
}
}Pages can be bound to aggregates. When HasPage = true on a [PageWidget], each aggregate instance gets its own page node in the tree. A Product named "Laptop X1" in the Electronics category gets the page /catalog/electronics/laptop-x1 automatically.
PageWidget Attribute -- Declaring Widget Types
The [PageWidget] attribute is the DSL part. It declares a widget type that content editors can place on pages at runtime:
[MetaConcept("PageWidget")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class PageWidgetAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
[MetaReference("Module", "AggregateRoot")]
public string? Module { get; set; }
[MetaProperty("DisplayType")]
public DisplayType DisplayType { get; set; } = DisplayType.Custom;
[MetaProperty("HasPage")]
public bool HasPage { get; set; }
public PageWidgetAttribute(string name) => Name = name;
}
[MetaConcept("WidgetFilter")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class WidgetFilterAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
public WidgetFilterAttribute(string name) => Name = name;
}
[MetaConcept("WidgetConfig")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class WidgetConfigAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
[MetaProperty("DefaultValue")]
public object? DefaultValue { get; set; }
public WidgetConfigAttribute(string name) => Name = name;
}
public enum DisplayType { List, Show, Form, Custom }[MetaConcept("PageWidget")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class PageWidgetAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
[MetaReference("Module", "AggregateRoot")]
public string? Module { get; set; }
[MetaProperty("DisplayType")]
public DisplayType DisplayType { get; set; } = DisplayType.Custom;
[MetaProperty("HasPage")]
public bool HasPage { get; set; }
public PageWidgetAttribute(string name) => Name = name;
}
[MetaConcept("WidgetFilter")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class WidgetFilterAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
public WidgetFilterAttribute(string name) => Name = name;
}
[MetaConcept("WidgetConfig")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class WidgetConfigAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
[MetaProperty("DefaultValue")]
public object? DefaultValue { get; set; }
public WidgetConfigAttribute(string name) => Name = name;
}
public enum DisplayType { List, Show, Form, Custom }Usage:
[PageWidget("ProductList", Module = "Product",
DisplayType = DisplayType.List, HasPage = false)]
public partial class ProductListWidget
{
[WidgetFilter("Category")]
public partial CategoryId? CategoryFilter { get; }
[WidgetConfig("MaxPerPage", DefaultValue = 10)]
public partial int MaxPerPage { get; }
[WidgetConfig("OrderBy", DefaultValue = "Name")]
public partial string OrderBy { get; }
}
[PageWidget("ProductShow", Module = "Product",
DisplayType = DisplayType.Show, HasPage = true)]
// HasPage = true → each Product gets a page node in the tree
public partial class ProductShowWidget { }
[PageWidget("CartSummary", DisplayType = DisplayType.Custom)]
// No Module → custom widget, not bound to an aggregate
public partial class CartSummaryWidget
{
[WidgetConfig("ShowItemCount", DefaultValue = true)]
public partial bool ShowItemCount { get; }
}[PageWidget("ProductList", Module = "Product",
DisplayType = DisplayType.List, HasPage = false)]
public partial class ProductListWidget
{
[WidgetFilter("Category")]
public partial CategoryId? CategoryFilter { get; }
[WidgetConfig("MaxPerPage", DefaultValue = 10)]
public partial int MaxPerPage { get; }
[WidgetConfig("OrderBy", DefaultValue = "Name")]
public partial string OrderBy { get; }
}
[PageWidget("ProductShow", Module = "Product",
DisplayType = DisplayType.Show, HasPage = true)]
// HasPage = true → each Product gets a page node in the tree
public partial class ProductShowWidget { }
[PageWidget("CartSummary", DisplayType = DisplayType.Custom)]
// No Module → custom widget, not bound to an aggregate
public partial class CartSummaryWidget
{
[WidgetConfig("ShowItemCount", DefaultValue = true)]
public partial bool ShowItemCount { get; }
}The generator produces for each [PageWidget]:
- A Blazor component that renders the widget
- An API endpoint that serves the widget's data
- A widget config form for the admin page builder
Built-in Widget Types
The CMF ships built-in widgets that require no [PageWidget] declaration:
Content widgets -- static content blocks placed by editors:
Title-- heading textText-- plain text paragraphImage-- single image with captionRichText-- formatted HTML contentStreamField-- renders a ContentBlock stream (connects the Block DSL to the Page DSL)
Navigation widgets -- auto-generated from page tree:
Breadcrumb-- ancestor chain from current page to rootMenu-- renders page tree children, configurable depth
Domain widgets -- auto-generated from aggregates via [PageWidget]:
List-- paginated, sortable, filterable listShow-- single entity detail viewForm-- create/edit form built from aggregate properties
The Rendering Pipeline
When a URL request arrives:
Each WidgetInstance in a Zone is deserialized from ConfigJson, matched to its [PageWidget] type, and rendered by the corresponding Blazor component. The shared kernel ensures widget components compile to both WASM (for the frontend SPA) and Server (for the admin page builder preview).