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;
}[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; }
}[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)[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;
}[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 { }[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; }
}[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// <auto-generated/> — EF Core mapping for StreamField
builder.Property(e => e.Body)
.HasColumnType("jsonb") // Postgres
.HasConversion<PageContentStreamJsonConverter>(); // System.Text.JsonThe 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:
- 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.