Skip to content

Blog Architecture

Technical internals of the SkyCMS blogging subsystem for developers extending or maintaining it.

Audience: Developers


Overview

The blogging subsystem reuses the core Article entity and content pipeline but adds blog-specific organization, rendering, and URL routing. Blog streams group posts by a shared BlogKey, and a dedicated rendering service generates client-side-rendered stream pages.


Entity Model

Blogs do not have their own database table. Both blog streams and blog posts are stored as Article records, distinguished by the ArticleType field:

ArticleType Value Description
General 0 Standard content page
BlogPost 1 Individual blog post
BlogStream 2 Blog stream definition (metadata container)
SpaApp 3 Single Page Application

Key Fields for Blogging

Field Usage
BlogKey Groups posts with their parent stream. Pattern: ^[a-z0-9-_]+$. Default: "default".
ArticleType Discriminator: 1 for posts, 2 for streams.
Introduction Post excerpt/summary (max 512 chars).
BannerImage Featured image URL.
Category Taxonomy label (max 64 chars).
Content For streams: the generated wrapper HTML. For posts: the authored HTML body.

CQRS Architecture

Blog features follow the vertical-slice CQRS pattern used throughout SkyCMS.

Commands

Command Handler Location Purpose
CreateBlogPostCommand Editor/Features/Blogs/CreatePost/ Create a new post in a stream
UpdateBlogPostCommand Editor/Features/Blogs/UpdatePost/ Update post content/metadata
UpdateBlogStreamCommand Editor/Features/Blogs/UpdateStream/ Update stream metadata, regenerate wrapper
DeleteBlogStreamCommand Editor/Features/Blogs/DeleteStream/ Soft-delete stream + cascade to entries
DeleteBlogPostCommand Editor/Features/Blogs/DeletePost/ Soft-delete individual post

Queries

Query Handler Location Purpose
GetBlogStreamQuery Common/Features/Blogs/Queries/ Fetch stream metadata + latest post + count
GetBlogPostQuery Common/Features/Blogs/Queries/ Fetch post content with prev/next navigation
GetBlogPostNavigationQuery Common/Features/Blogs/Queries/ Fetch previous/next post links for a post

Command Flow Example: Creating a Post

BlogController.CreateEntry(blogKey, title)
  → Validate parent stream exists
  → CreateBlogPostCommand { BlogKey, Title, UserId, Published=null }
    → CreateBlogPostCommandHandler
      → Delegates to CreateArticleCommand (reuses core article pipeline)
      → Sets ArticleType = BlogPost, BlogKey = parent key
      → Returns { Id, ArticleNumber, UrlPath }
  → Redirect to editor for the new article

Command Flow: Updating a Stream

BlogController.Edit(id, model)
  → UpdateBlogStreamCommand { Id, Title, Description, HeroImage, Published }
    → UpdateBlogStreamHandler
      → Validates title uniqueness
      → Updates article fields
      → Calls BlogStreamRenderingService.GenerateBlogStreamWrapperAsync()
      → Saves regenerated wrapper HTML to Article.Content

Rendering Service

Interface: IBlogStreamRenderingService (in Common/Services/BlogPublishing/)

Implementation: BlogStreamRenderingService

Methods

Method Purpose
GenerateBlogStreamWrapperAsync(article, blogKey) Generates the blog stream index page HTML with embedded post metadata JSON
GenerateBlogPostMetadataJsonAsync(blogKey) Generates a JSON array of all published posts for a stream
GenerateBlogPostSnippetAsync(article) Generates a single post as an HTML snippet

Client-Side Rendering Pattern

Blog stream pages use a client-side rendering approach:

Server generates:
┌─────────────────────────────────────┐
│ Stream header (title, description)  │
│ <script type="application/json">    │
│   [ { post metadata array } ]       │
│ </script>                            │
│ <div id="post-list"></div>           │  ← Empty, filled by JS
│ <ul id="pagination"></ul>            │  ← Populated by JS
│ <script src="blog-stream-loader.js">│
└─────────────────────────────────────┘

Client-side:
1. blog-stream-loader.js reads embedded JSON
2. Renders post cards into #post-list
3. Builds pagination into #pagination
4. Post detail links navigate to /{blogKey}/{postSlug}

CSS Class Convention

All blog CSS classes follow a BEM-like naming convention:

  • Stream elements: sky-blog-stream-{element} (e.g., sky-blog-stream-header, sky-blog-stream-row)
  • Post elements: sky-blog-post-{element} (e.g., sky-blog-post-title, sky-blog-post-content)
  • Navigation: sky-blog-stream-nav-{element} (e.g., sky-blog-stream-nav-item, sky-blog-stream-nav-link)

Controller

File: Editor/Controllers/BlogController.cs

Route base: /editor/blogs

Authorization: [Authorize] on the controller (all actions) except PreviewStream which is [AllowAnonymous].

Action Summary

Action HTTP Route Notes
Index GET /editor/blogs Lists all streams
Create GET/POST /editor/blogs/create Create stream form + handler
Edit GET/POST /editor/blogs/{id:guid}/edit Edit stream form + handler
Delete GET /editor/blogs/{id:guid}/delete Delete confirmation
ConfirmDelete POST /editor/blogs/{id:guid}/confirmdelete Execute delete
Entries GET /editor/blogs/{blogKey}/entries Lists posts in stream
CreateEntry GET /editor/blogs/{blogKey}/entries/create/{title} Creates post (see note below)
DeleteEntry GET /editor/blogs/{blogKey}/entries/{articleNumber:int}/delete Entry delete confirmation
ConfirmDeleteEntry POST /editor/blogs/{blogKey}/entries/{articleNumber:int}/confirmdeleteentry Execute entry delete
PreviewStream GET /editor/blogs/{blogKey}/preview Anonymous preview
GetBlogs GET /editor/blogs/GetBlogs JSON: all streams
GetEntries GET /editor/blogs/{blogKey}/getentries JSON: posts in stream

Design note: CreateEntry uses [HttpGet] but performs a database write (creates a new article). This is a side-effect on GET, which violates HTTP conventions. It is called from a JavaScript modal that navigates to the URL with the title embedded in the path.


Cosmos DB Compatibility

Blog queries follow the project-wide Cosmos DB compatibility rules:

  • No cross-container joins between streams and posts — queries are sequential with client-side correlation.
  • Enum-to-int conversions (e.g., ArticleType.BlogStream) are pre-computed into local variables before LINQ predicates.
  • The GetBlogs action fetches all blog-type articles, then groups by ArticleNumber client-side to get latest versions.

Validation Rules

Rule Constraint
BlogKey format ^[a-z0-9-_]+$
BlogKey uniqueness Enforced — conflicts return validation error
BlogKey max length 64 characters (validation), 128 characters (storage)
Stream title Required, max 128 characters
Post title Required, max 254 characters
Description Required for streams, max 512 characters
URL path Auto-generated via slug service, max 1999 characters
Published Nullable — null = draft, value = published

See Also