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

Content Parts and Blocks DSL (M2) -- Composable Content

This DSL synthesizes two patterns from the broader CMF landscape: Content Parts from Orchard Core (.NET) and StreamField from Wagtail (Django).

Content Parts -- Horizontal Composition

A Content Part is a reusable cross-cutting concern that adds a group of properties and behavior to any content type. It is the CMS equivalent of a mixin or trait.

[MetaConcept("ContentPart")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class ContentPartAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    public ContentPartAttribute(string name) => Name = name;
}

[MetaConcept("PartField")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class PartFieldAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaProperty("Required")]
    public bool Required { get; set; }

    [MetaProperty("MaxLength")]
    public int? MaxLength { get; set; }

    public PartFieldAttribute(string name) => Name = name;
}

[MetaConcept("HasPart")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class HasPartAttribute : Attribute
{
    [MetaReference("Part", "ContentPart", Multiplicity = Multiplicity.One)]
    public string Part { get; }

    public HasPartAttribute(string part) => Part = part;
}

Defining parts:

[ContentPart("Routable")]
public partial class RoutablePart
{
    [PartField("Slug", Required = true, MaxLength = 200)]
    public partial string Slug { get; }

    [PartField("CanonicalUrl")]
    public partial string? CanonicalUrl { get; }
}

[ContentPart("Seoable")]
public partial class SeoablePart
{
    [PartField("MetaTitle", MaxLength = 70)]
    public partial string? MetaTitle { get; }

    [PartField("MetaDescription", MaxLength = 160)]
    public partial string? MetaDescription { get; }

    [PartField("OpenGraphImage")]
    public partial MediaRef? OpenGraphImage { get; }

    [PartField("NoIndex")]
    public partial bool NoIndex { get; set; }
}

[ContentPart("Taggable")]
public partial class TaggablePart
{
    [PartField("Tags")]
    public partial IReadOnlyList<TaxonomyNodeRef> Tags { get; }
}

[ContentPart("Auditable")]
public partial class AuditablePart
{
    [PartField("CreatedAt")] public partial DateTimeOffset CreatedAt { get; }
    [PartField("CreatedBy")] public partial string CreatedBy { get; }
    [PartField("UpdatedAt")] public partial DateTimeOffset UpdatedAt { get; }
    [PartField("UpdatedBy")] public partial string UpdatedBy { get; }
}

Attaching parts to an aggregate:

[AggregateRoot("Product", BoundedContext = "Catalog")]
[HasPart("Routable")]
[HasPart("Seoable")]
[HasPart("Taggable")]
[HasPart("Auditable")]
public partial class Product
{
    [EntityId] public partial ProductId Id { get; }
    [Property("Name", Required = true, MaxLength = 200)]
    public partial string Name { get; }
    [Composition] public partial Money Price { get; }
    [Composition] public partial IReadOnlyList<ProductVariant> Variants { get; }
}

// Generated: Product now has these additional properties:
// .Slug, .CanonicalUrl                          (from Routable)
// .MetaTitle, .MetaDescription, .OpenGraphImage  (from Seoable)
// .Tags                                          (from Taggable)
// .CreatedAt, .CreatedBy, .UpdatedAt, .UpdatedBy (from Auditable)

The generator extends the partial class with part properties, adds EF Core column mappings, and includes part fields in DTOs and admin forms.

Content Blocks -- Vertical Composition

Content Blocks are typed, composable content units stored as structured JSON. They replace free-form rich text with structured, validated, renderable content.

Three block primitives:

[MetaConcept("StructBlock")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class StructBlockAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    public StructBlockAttribute(string name) => Name = name;
}

[MetaConcept("ListBlock")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class ListBlockAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaReference("ItemBlock", "StructBlock", Multiplicity = Multiplicity.One)]
    public string ItemBlock { get; set; }

    public ListBlockAttribute(string name) => Name = name;
}

[MetaConcept("StreamBlock")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class StreamBlockAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaProperty("AllowedBlocks", Required = true)]
    public string[] AllowedBlocks { get; set; }

    public StreamBlockAttribute(string name) => Name = name;
}

[MetaConcept("BlockField")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class BlockFieldAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaProperty("Required")]
    public bool Required { get; set; }

    public BlockFieldAttribute(string name) => Name = name;
}

[MetaConcept("StreamField")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class StreamFieldAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaReference("StreamBlock", "StreamBlock", Multiplicity = Multiplicity.One)]
    public string StreamBlock { get; set; }

    public StreamFieldAttribute(string name) => Name = name;
}

Defining blocks:

[StructBlock("Hero")]
public partial class HeroBlock
{
    [BlockField("Title", Required = true)]
    public partial string Title { get; }

    [BlockField("Subtitle")]
    public partial string? Subtitle { get; }

    [BlockField("Image")]
    public partial MediaRef? BackgroundImage { get; }

    [BlockField("CallToAction")]
    public partial Link? Cta { get; }
}

[StructBlock("Testimonial")]
public partial class TestimonialBlock
{
    [BlockField("Quote", Required = true)]
    public partial string Quote { get; }

    [BlockField("Author")]
    public partial string? Author { get; }

    [BlockField("Rating")]
    public partial int? Rating { get; }
}

[StructBlock("RichText")]
public partial class RichTextBlock
{
    [BlockField("Content", Required = true)]
    public partial string HtmlContent { get; }
}

[ListBlock("Gallery", ItemBlock = "Image")]
public partial class GalleryBlock { }

[StreamBlock("PageContent",
    AllowedBlocks = new[] { "Hero", "RichText", "Testimonial", "Gallery" })]
public partial class PageContentStream { }

Using a StreamField on an aggregate:

[AggregateRoot("Article", BoundedContext = "Content")]
[HasPart("Routable")]
[HasPart("Seoable")]
[HasPart("Auditable")]
public partial class Article
{
    [EntityId] public partial ArticleId Id { get; }

    [Property("Title", Required = true, MaxLength = 200)]
    public partial string Title { get; }

    [Property("Excerpt", MaxLength = 500)]
    public partial string? Excerpt { get; }

    [StreamField("Body", StreamBlock = "PageContent")]
    public partial PageContentStream Body { get; }
}

Blocks are not entities -- they have no identity and no lifecycle. They are structured content stored as a JSON column in EF Core:

// <auto-generated/> — EF Core mapping for StreamField
builder.Property(e => e.Body)
    .HasColumnType("jsonb")                          // Postgres
    .HasConversion<PageContentStreamJsonConverter>(); // System.Text.Json

The generator produces: C# block types with JSON serialization, a Blazor block editor component per block type, and a StreamField renderer for the frontend.

Three Composition Axes

Content Parts, Content Blocks, and Page Widgets are three distinct composition mechanisms that work together on a single content type:

Diagram
Three orthogonal axes of composition on one Article: Content Parts add capabilities horizontally, StreamFields structure content vertically, and Page Widgets arrange the result on any page.
  • Content Parts = horizontal composition on the type. They add cross-cutting properties and behavior: Routable, Seoable, Taggable, Auditable.
  • Content Blocks = vertical composition within a field. They structure rich content: a Hero block, followed by a RichText block, followed by a Gallery block, all within a single StreamField property.
  • Page Widgets = composition on the page. They arrange domain data on a rendered page: an ArticleList widget in the "content" zone, a Menu widget in the "sidebar" zone.
⬇ Download