Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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 }

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)

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);
    }
}

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 }

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; }
}

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 text
  • Text -- plain text paragraph
  • Image -- single image with caption
  • RichText -- formatted HTML content
  • StreamField -- 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 root
  • Menu -- renders page tree children, configurable depth

Domain widgets -- auto-generated from aggregates via [PageWidget]:

  • List -- paginated, sortable, filterable list
  • Show -- single entity detail view
  • Form -- create/edit form built from aggregate properties

The Rendering Pipeline

When a URL request arrives:

Diagram
How a single URL is rendered end-to-end — the router resolves the page from the tree, Blazor picks up the layout, and each widget pulls its own data from its own API endpoint.

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).

⬇ Download