0 / 0

Relatude.CMS#

A practical manual

Relatude.CMS is the content layer on top of Relatude.DB, the open-source object-oriented graph database for .NET. It ships as the NuGet package Relatude.DB.CMS and gives a web application a content model (items, pages, templates, sites, users), a pluggable way to render pages with MVC views, controllers, Razor Pages or any IResult, CMS settings, privacy-safe page-view analytics, fast HTML utilities, a donations model, and an API with a TypeScript SDK for the CMS interface editors work in and the plugins that extend it.

This manual covers what the CMS adds. The database itself (modelling, writing, querying, URLs, files, logs) is covered by the Relatude.DB manual, and is linked where it matters.

Version note. Relatude.CMS is pre-1.0 and targets .NET 10. Everything here is taken from the source in Relatude.CMS/. When something here disagrees with your build, the source wins.


Part I — Setting up#

1. What Relatude.CMS is#

Relatude.CMS is a class library with no entry point. A host ASP.NET Core application starts Relatude.DB, adds the CMS, owns the schema through the admin UI at /relatude.db, and decides how pages look. The CMS supplies the parts every content site needs and leaves the rest to the host.

Part What it gives the host Namespace
Content model IItem, IPage, ITemplate, ISite, ITree, users, groups, countries Relatude.CMS.Models
Content rendering URL to content to view, controller, Razor Page or IResult Relatude.DB.CMS.Web.Rendering
Settings a CMS tab per database in the admin UI Relatude.CMS.Settings
Analytics page views in a custom log, with bot detection, no cookies Relatude.DB.CMS.Web.Analytics
HTML utilities a span-based HTML reader and rewriter Relatude.CMS.Utils
Donations a donation node type and its admin projection Relatude.CMS.Models.Donations
CMS API the NodeStore over HTTP, as the signed-in user, and the CMS interface Relatude.DB.CMS.Api
TypeScript SDK and plugins the store and query builder in TypeScript, and the plugin contract @relatude/sdk, @relatude/ui

Two packages build on it: Relatude.DB.Ecom (products, orders, carts, pricing) and Relatude.DB.Marketing (newsletters and deliveries). Each has its own README.

Content types are C# interfaces. Relatude.DB generates the implementing classes, so a site declares IArticle : IPage and never writes the class. Relations between nodes are small marker classes, and every read and write goes through a NodeStore. The Relatude.DB manual explains all three in depth; this manual assumes them.

2. Setting up the host#

The smallest setup#

Six lines in Program.cs make a working CMS host:

var builder = WebApplication.CreateBuilder(args);
builder.AddRelatudeCMS().AddMvcRendering();

var app = builder.Build();
app.UseRelatudeCMS();
app.MapRelatudeCMS();
app.Run();
Call What it does
AddRelatudeCMS() adds Relatude.DB, the CMS settings, analytics, and content routing: turning a URL into a page or a stored file
.AddMvcRendering() renders pages with MVC views and controllers, and lets templates choose which; .AddRazorPagesRendering() adds Razor Pages beside it
UseRelatudeCMS() starts the database and its admin UI, and adds the analytics middleware
MapRelatudeCMS() answers every URL the database knows: a page is rendered, a file is sent

The calls are in the global namespace, so Program.cs needs no using for them.

What the defaults give you#

Started like this, the site:

A first page#

What the setup does not know yet is your content. A page takes three more pieces: a content type, its registration, and something to render it.

// Models/IArticle.cs: a content type is an interface, and Relatude.DB writes the class
using Relatude.CMS.Models;
using Relatude.DB.Nodes;

namespace MySite.Models;

public interface IArticle : IPage {
    [HtmlProperty] string Body { get; set; }
}
// Program.cs: the smallest setup, with the content type registered and rendered
using MySite.Models;
using Relatude.DB.Datamodels;

var builder = WebApplication.CreateBuilder(args);
builder.AddRelatudeDB(o => o.OnDatamodelInit = (dm, _) => {
    dm.AddCmsContentModel();      // IItem, IPage and the templates
    dm.Add<IArticle>();           // the site's own types
});
builder.AddRelatudeCMS()
    .AddMvcRendering()
    .AddRendering(o => o.RenderRazorView<IArticle>());   // every article renders with the view "Article"
@* Views/Shared/Article.cshtml *@
@using MySite.Models
@model IArticle
<h1>@Model.Name</h1>
@Html.Raw(Model.Body)

An article needs an address to have a URL. Here the site creates one the first time it starts:

builder.AddRelatudeDB(o => o.OnStoreOpenBackground = db => {
    if (db.Query<IArticle>().Count() > 0) return;
    var article = db.CreateAndInsert<IArticle>(a => { a.Name = "Hello world"; a.Body = "<p>It works.</p>"; });
    db.UpdateAddress(article.Id, "hello-world");
});

GET /hello-world now renders Views/Shared/Article.cshtml with the article as its model. The second AddRelatudeDB call is not a mistake: every call configures the same options.

The options#

Each option below is something added to the smallest setup. None of them has to come in a particular order.

Configuring the database#

AddRelatudeCMS() adds Relatude.DB with its defaults. Call AddRelatudeDB(o => …) yourself, before or after it and as often as you like, to configure it.

Option Use it to Example
OnDatamodelInit register content types in code (dm, _) => dm.AddCmsContentModel()
CreateUrlManager shape the URLs: a home page, a page tree, domains (chapter 6) _ => new DefaultUrlManager(new() { FallbackRootId = homeId })
OnStoreInit register transaction plugins and task runners, before the store opens db => db.RegisterTransactionPlugin(new PageAddressPlugin())
OnStoreOpenBackground seed content and warm caches once the store is open, off the request path db => Seeder.SeedIfEmpty(db)
DefaultDataFolderPath keep relatude.data/ and relatude.settings/ somewhere other than the project folder "D:/sites/mysite"

The Relatude.DB manual lists the rest in Options and events in Program.cs.

Registering content types#

Content types are registered in code, as above, or as datamodel sources in the admin UI. Both add up: the sources load first, then OnDatamodelInit runs. A source names a namespace, and matching is exact, so every namespace with node types is a source of its own:

"DatamodelSources": [
  { "Id": "<unique guid>", "Name": "CMS",       "Namespace": "Relatude.CMS.Models",           "Type": "AssemblyNameReference", "Reference": "Relatude.DB.CMS" },
  { "Id": "<unique guid>", "Name": "My site",   "Namespace": "MySite.Models",                 "Type": "AssemblyNameReference", "Reference": null },
  { "Id": "<unique guid>", "Name": "Donations", "Namespace": "Relatude.CMS.Models.Donations", "Type": "AssemblyNameReference", "Reference": "Relatude.DB.CMS" }
]

Choosing how pages render#

Add the rendering systems a site uses, one call each:

Call Adds Needed for
AddMvcRendering() MVC controllers and views (AddControllersWithViews()) pages rendered by a view or a controller
AddRazorPagesRendering() Razor Pages (AddRazorPages()) pages rendered by a Razor Page
AddRendering(o => …) code defaults per type, selectors and template kinds (chapter 8) deciding in code
builder.AddRelatudeCMS().AddMvcRendering();                          // views and controllers
builder.AddRelatudeCMS().AddRazorPagesRendering();                   // Razor Pages
builder.AddRelatudeCMS();                                            // no rendering: files, JSON and GetContent<T>() still work

A host's own AddControllersWithViews(o => …) or AddRazorPages() works beside these. They add to the same MVC, so its filters and options apply to content requests too.

The request pipeline#

The smallest setup leaves the order of the middleware to ASP.NET Core. A real site adds static files, its own routes and a fallback, and this is the order that works:

var app = builder.Build();

app.UseRelatudeCMS();                        // the database first: while it opens, requests get a progress page
app.UseStaticFiles();                        // wwwroot, before routing...
app.UseRouting();                            // ...so a request for a static file never looks for content
app.UseAuthentication();                     // what runs after UseRouting runs for pages too
app.UseAuthorization();

app.MapGet("/api/status", () => "ok");       // explicit routes win over content
app.MapRelatudeCMS();                    // pages and stored files
app.MapFallback(() => Results.NotFound());   // what neither answers

app.Run();

UseRelatudeCMS() calls UseRelatudeDB(), so a host that calls both starts the database once.

Who the visitor is#

Authentication is ASP.NET Core's: cookies, Identity, OpenID Connect, whichever the site uses. The CMS needs one thing from it, which user node the signed-in visitor is, and the store does the rest: every read as that visitor honours the read access editors set on pages, by group, in the admin UI.

sign-in (the host's)
      │
      ▼
ClaimsPrincipal ──► the CMS: which ICMSUser is this? ──► the store reads as that user:
                    the NameIdentifier claim is the node's id,   read access by group,
                    else the Email claim finds the node          published content only

Sign a user in with CmsPrincipal.For, which writes the id the CMS reads back, with the user's name, email and groups as roles:

app.MapPost("/login", async (RelatudeCmsContext ctx, string email, string password) => {
    var user = Members.Verify(ctx.Admin, email, password);     // the site's own password check
    if (user == null) return Results.Unauthorized();
    await ctx.Http.SignInAsync(CmsPrincipal.For(user, CookieAuthenticationDefaults.AuthenticationScheme));
    return Results.Redirect("/");
});

[Authorize(Roles = "Editors")] then works on the site's own controllers, with the names of the user's groups as roles. Users are ICMSUser nodes and groups ICmsUserGroup nodes, which need the engine's user model in the datamodel: dm.AddNamespace<ISystemUser>(), then dm.Add<ICMSUser>() and dm.Add<ICmsUserGroup>().

A page the visitor may not read sends an anonymous visitor to the login page and answers a signed-in one with 403 (chapter 7). AddRelatudeCMS(o => …) sets the three things that differ between sites:

Option Default Use it to
User the NameIdentifier claim as the node's id, else the user with the Email claim find the user node another way, such as from a header a gateway sets
Culture the URL's culture, else the user's preferred one, else the store's default choose the culture from the host name, or a cookie
LoginRedirect on off: content the visitor may not read is answered as if it did not exist
builder.AddRelatudeCMS(o => o.Culture = ctx => ctx.Http.Request.Host.Host.EndsWith(".no") ? "nb-NO" : "en-GB");

Website2 has all of it running: members with passwords, a cookie sign-in, articles for a group only, and an open page to set members, groups and access, so the rules can be tried out.

Using the database in code#

NodeStore (namespace Relatude.DB.Nodes) is the whole data API. In a request, RelatudeCmsContext (namespace Relatude.DB.CMS.Web) hands out the store and knows the request; code with no request reads through IRelatudeDBAccessor:

Way Reads as Use it in
inject RelatudeCmsContext and read .Db the visitor: the signed-in user's read access, the request's culture, published content only controllers, views, services: anything in a request
RelatudeCmsContext.Admin the master admin: no read access or publication filtering trusted code in a request, never to render content for the visitor
inject IRelatudeDBAccessor (Relatude.CMS.Engine) and read .Database the store's default context, an anonymous visitor hosted services and other code with no request
RelatudeDBRuntime.Database the master admin places without dependency injection
public class NewsController(RelatudeCmsContext ctx) : Controller {
    public IActionResult Index() => View(ctx.Db.Query<IArticle>().Page(0, 10).Execute());
}
@inject RelatudeCmsContext Ctx
@foreach (var article in Ctx.Db.Query<IArticle>().Page(0, 5).Execute()) {
    <a href="@Ctx.GetUrl(article)">@article.Name</a>
}
// a hosted service can start before the database: IsOpen is false, never an exception, until it is open
public class Warmup(IRelatudeDBAccessor db) : BackgroundService {
    protected override async Task ExecuteAsync(CancellationToken stop) {
        while (!db.IsOpen) await Task.Delay(500, stop);
        var articles = db.Database.Query<IArticle>().Count();
    }
}
RelatudeCmsContext Holds
Db, Admin the store as the visitor, and as the master admin
User, UserId, IsAuthenticated the signed-in visitor as an ICMSUser node, or null
CultureCode the culture the request reads in
Url, Node, AccessDenied, CanRead(key) what the URL addresses, the node read as the visitor, and whether the visitor may read a node
Content, Render the page with its template, and what renders it (chapter 12)
GetUrl(node), GetUrl(file) outbound URLs, through Db
Http, IsOpen the HttpContext, and whether the database is open yet

One per request, and each part is worked out on first use, so a view that never asks for the user never looks it up. Content routing reads through it, as the visitor (chapter 7). Methods that write data take the NodeStore db as a parameter rather than capturing one.

Hooking in from a library#

A library registers its transaction plugins and task runners with an IStoreInitializer service (namespace Relatude.DB.NodeServer) rather than with OnStoreInit, which belongs to the host and which a later assignment would replace. The server calls every one for each store before it opens:

public sealed class AuditInitializer : IStoreInitializer {
    public void Initialize(NodeStore store) => store.RegisterTransactionPlugin(new AuditPlugin());
}

services.TryAddEnumerable(ServiceDescriptor.Singleton<IStoreInitializer, AuditInitializer>());

The CMS registers its own save checks this way. Two more services cover the other moments a library needs: an IDatamodelInitializer adds its model types to every datamodel, after OnDatamodelInit, and an IStoreOpenHandler seeds data or starts work once a store is open, on a background thread. A CMS module is all of this, and more, described in one class (chapter 18).

What AddRelatudeCMS registers#

Service Lifetime Role
IRelatudeDBAccessor → RelatudeDBAccessor singleton the default store, and whether it is open
RelatudeCmsContext scoped the request: the visitor, the culture, the URL, and the store as the visitor
CmsRequestOptions singleton how a request is read: User, Culture, LoginRedirect
ContentRenderingOptions, ContentResolver singleton content routing: what a URL addresses, and what renders it
IStoreInitializer → TemplateStoreInitializer singleton the checks made when pages are saved, on every store with the CMS model
CmsModules singleton the modules added with AddModule, in order (chapter 18)
IDatamodelInitializer, IStoreInitializer, IStoreOpenHandler → CmsModule… singleton the modules' part in every database's start
ICmsLicense → RelatudeCmsLicense singleton what the license carries, for modules that need a feature
TemplateCheckService hosted service logs a check of the rendering when the store opens
ICustomSettingsDefinition → CmsSettingsDefinition singleton the CMS settings tab
IAnalyticsHost → RelatudeAnalyticsHost singleton analytics' access to the database
PageViewQueue, DailyVisitorHash singleton analytics
AnalyticsWorker hosted service records page views in the background

Replace a service by registering your own, before or after AddRelatudeCMS(), the way a preview resolver replaces ContentResolver (chapter 7).

Everything together#

Website2 in the repository puts the options together: a page tree with a home page at /, code defaults for every page type, templates for articles, a selector, and the pipeline above. Its Program.cs, shortened:

using Relatude.CMS.Models;
using Relatude.DB.CMS.Web.Rendering;
using Relatude.DB.Web;

var builder = WebApplication.CreateBuilder(args);

builder.AddRelatudeDB(o => {
    o.OnDatamodelInit = (dm, _) => {
        dm.AddCmsContentModel();
        dm.AddNamespace<ISitePage>();                // the page types and the PageTree relation
    };
    o.CreateUrlManager = _ => new DefaultUrlManager(new DefaultUrlManagerOptions {
        Parents = [new UrlParentRelation { ParentRelationName = nameof(PageTree) }],   // /products/bikes/trail-one
        FallbackRootId = SiteSeeder.HomeId,          // the home page at "/"
    });
    o.OnStoreOpenBackground = SiteSeeder.SeedIfEmpty;
});

builder.AddRelatudeCMS()
    .AddMvcRendering().AddRazorPagesRendering()
    .AddRendering(o => o
        .Select((http, m) => http.Request.Query["format"] == "json" ? new ResultTarget((_, c) => Results.Json(new { c.Content.Name })) : null)
        .RenderRazorView<IHomePage>("Home")
        .RenderMvcController<ISectionPage>("Section")
        .RenderRazorView<IProductPage>(p => p.InStock ? "Product" : "ProductSoldOut")
        .RenderRazorPage<ILandingPage>("Landing")
        .RenderRazorView<IPage>("Default"));              // any other page

var app = builder.Build();

app.UseRelatudeCMS();
app.UseStaticFiles();
app.UseRouting();
app.MapRelatudeCMS();
app.MapFallback(() => Results.NotFound("No page here."));

app.Run();

Its README lists the URLs to try. Chapter 24 goes further and puts every option in one Program.cs.


Part II — The content model#

3. Content types#

Every CMS node is an IItem. Routable content is an IPage.

public interface IItem {
    Guid Id { get; set; }
    [StringProperty(Indexed = true)] string Name { get; set; }
    NodeMeta Meta { get; }                     // created, changed, culture, address (Meta.Address)…
}

public interface IPage : IItem {
    string MetaTitle { get; set; }
    string MetaDescription { get; set; }
    string MetaKeywords { get; set; }
    bool ShowInMenu { get; set; }
    bool ShowInTree { get; set; }
    Reference<ITemplate> Template { get; set; }     // the page's template, or none
}

A site declares its own content types on top of them:

using Relatude.CMS.Models;

namespace MySite.Models;

public interface IArticle : IPage {
    string Intro { get; set; }
    string Body { get; set; }
}

public interface INewsList : IPage {
    int PageSize { get; set; }
}

public interface ILandingPage : IPage {
    string Headline { get; set; }
}

Creating, reading and changing them is plain Relatude.DB:

var article = db.Create<IArticle>();     // not stored yet
article.Name = "Hello world";
article.Intro = "The first article.";
db.Insert(article);

var loaded = db.Get<IArticle>(article.Id);
loaded.Intro = "Changed.";
db.Update(loaded);                       // a changed node is stored only by Update

var latest = db.Query<IArticle>().OrderByMetaDescending(m => m.CreatedUtc).Take(10).Execute();

Template is a Reference<ITemplate>: read it with .TryGet() and .IsSet(), and set it with .Set(id) before the page is saved. Relation ends, such as the Parent and Children of a page tree, are objects too; change them with db.SetRelation, AddRelation, RemoveRelation and ClearRelation, never by assignment.

4. The other base types#

Type Extends Members Use
ISite ISystemCollection (from Relatude.DB) Id, Name, Cultures a site: a collection with its cultures
ITree none a marker for tree structures
ICMSUser ISystemUser Name, Email, Forename, Surname, Company, JobTitle, Mobile, Phone, CultureCode, Meta a person who signs in
ICmsUserGroup ISystemUserGroup (from Relatude.DB) GroupName, members and memberships a group of users
ICountry TwoLetterCode, ThreeLetterCode a country
IEmailContent FromEmail, FromEmailName, CCEmails, Subject, Body, ReplyToEmail the content of an email, embedded in other types

On ICMSUser, Email and Mobile are unique when not empty, and CultureCode holds the user's preferred culture, such as nb-NO, or nothing. Name is a stored, indexed property on purpose, not a display-name marker.

ICountry sits in the namespace Relatude.CMS, not Relatude.CMS.Models. A site that uses it registers that namespace as a datamodel source too.

5. Templates#

A template is a layout an editor can choose for a page. Where every page of a type looks the same, no template is needed: code says what renders the type (chapter 8). Where editors choose, say between a standard and a wide article layout, each choice is a template, and the page points at the one it uses:

the page                        the template                      the code
"Hello world", an IArticle      "Article, wide"
  Template ──────────────────►    ViewName  = "ArticleWide" ────►  Views/Shared/ArticleWide.cshtml,
                                  NodeTypes = [IArticle]            rendered with the page as its model

Templates are nodes, created in the admin UI like other content. Each has:

Field What it is
NodeTypes the page types the template is for. A type covers the types derived from it, so IPage means every page. Empty means any type.
its kind what renders it. The kind is the template's own node type, one of the four below.
Kind Renders with Its field
IRazorViewTemplate a Razor view, with the page as its model ViewName
IMvcControllerTemplate an action of one of the site's controllers Controller, and Action, Index when blank
IRazorPageTemplate a Razor Page PagePath
ITemplate, a plain template only a selector in Program.cs (chapter 8)

Editors create templates in the admin UI. The same in code:

Guid typeOf<T>() => db.Datastore.Datamodel.NodeTypesByFullName[typeof(T).FullName!].Id;

var wide = db.CreateAndInsert<IRazorViewTemplate>(t => {
    t.Name = "Article, wide";
    t.ViewName = "ArticleWide";
    t.NodeTypes = [typeOf<IArticle>()];
});

var article = db.CreateAndInsert<IArticle>(p => {
    p.Name = "Hello world";
    p.Template.Set(wide.Id);
});

Two things to know:


Part III — URLs#

6. Addresses and URLs#

A URL is never stored. A page has an address, one segment such as hello-world, and the store's URL manager builds the URL from it. The same manager turns a requested URL back into the page, so the two directions always agree.

The default: one address, one URL#

var article = db.CreateAndInsert<IArticle>(a => a.Name = "Hello world");
db.UpdateAddress(article.Id, "hello-world");

db.GetUrl(article);                    // "/hello-world"

A request for /hello-world now reaches the article (chapter 7). With nothing configured:

Good addresses#

Slugs. The store keeps only plain ASCII in an address. RelatudeUrlHelper.ToUrlSlug (namespace Relatude.CMS.Engine.Web) makes a readable one from a name first: lower case, æ, ø and å transliterated, other accents removed, everything else turned into single dashes, and node when nothing is left.

RelatudeUrlHelper.ToUrlSlug("Blåbær og Øl");   // "blabaer-og-ol"
RelatudeUrlHelper.ToUrlSlug("Café Crème!");    // "cafe-creme"

Collisions are the store's job. When an address would give two pages the same URL, it appends -2, -3 and so on as the transaction commits, so code that sets addresses never checks for them. Read the address back if you need it.

Editors set a page's address when its type has a property marked [AddressProperty]. The property is the address, so editing it in the admin UI moves the page:

public interface IArticle : IPage {
    [AddressProperty] string Slug { get; set; }
    [HtmlProperty] string Body { get; set; }
}

Automatically, a transaction plugin gives every new page an address from its name:

using Relatude.CMS.Engine.Web;
using Relatude.CMS.Models;
using Relatude.DB.Common;
using Relatude.DB.Nodes;
using Relatude.DB.Transactions;

public class PageAddressPlugin : NodeTransactionPlugin<IPage> {
    public override void OnBeforeNodeAction(NodeKey key, NodeOperation operation, Transaction transaction, NodeHelper<IPage> helper) {
        if (operation is NodeOperation.InsertOrFail or NodeOperation.InsertIfNotExists
            && helper.GetProperty(p => p.Name) is string name) {
            transaction.UpdateAddress(key.Guid, RelatudeUrlHelper.ToUrlSlug(name));
        }
        base.OnBeforeNodeAction(key, operation, transaction, helper);
    }
}

// Program.cs
builder.AddRelatudeDB(o => o.OnStoreInit = db => db.RegisterTransactionPlugin(new PageAddressPlugin()));

It acts on inserts only (db.Insert is InsertOrFail). Adding the update operations would move a page's URL whenever it is renamed, breaking links to it. Relatude.Ecom's ShopTemplateSetterPlugin does the same for products, on updates as well.

The options#

Everything else is an option of the URL manager. Set the options in the database's settings, in the admin UI or in relatude.db.json under LocalSettings.UrlOptions:

"UrlOptions": {
  "FallbackRootId": "7e1a0001-5eed-4c0d-9a11-000000000001",
  "Parents": [ { "ParentRelationName": "PageTree" } ]
}

or in code, where a manager made by CreateUrlManager replaces the settings file's options entirely:

using Relatude.DB.Web;

builder.AddRelatudeDB(o => o.CreateUrlManager = _ => new DefaultUrlManager(new DefaultUrlManagerOptions {
    FallbackRootId = HomeId,
    Parents = [new UrlParentRelation { ParentRelationName = "PageTree" }],
}));

The sections below take the options one at a time, each with what it does to the URLs.

A home page at /#

FallbackRootId makes a page the site's root. The root answers /, so it is the home page:

new DefaultUrlManagerOptions { FallbackRootId = HomeId }
/              the home page
/hello-world   the article, as before

The root is chosen by id, so create the home page with a fixed one:

public static readonly Guid HomeId = new("7e1a0001-5eed-4c0d-9a11-000000000001");

db.CreateAndInsert<IHomePage>(p => p.Name = "Home", new NodeKey(HomeId));

Page trees: /products/bikes/trail-one#

Parents names the relation that leads from a page to its parent. A page's URL is then its ancestors' addresses followed by its own, below the root:

public interface ISitePage : IPage {
    PageTree.Parent Parent { get; set; }
    PageTree.Children Children { get; set; }
}

public class PageTree : OneToMany<ISitePage, ISitePage> {
    public class Parent : One { }
    public class Children : Many { }
}

new DefaultUrlManagerOptions {
    FallbackRootId = HomeId,
    Parents = [new UrlParentRelation { ParentRelationName = nameof(PageTree) }],
}
Home (the root)            /
├─ Products                /products
│  └─ Bikes                /products/bikes
│     └─ Trail One         /products/bikes/trail-one
└─ News                    /news
   └─ Bikes                /news/bikes

Relations are set with db.AddRelation, before the child's address, so the store checks the address against the right siblings:

db.AddRelation<ISitePage>(products.Id, p => p.Children, bikes.Id);
db.UpdateAddress(bikes.Id, "bikes");

Several sites, one per domain#

Domains gives each host a root. A host serves the pages below its root, and the root answers /:

new DefaultUrlManagerOptions {
    Parents = [new UrlParentRelation { ParentRelationName = nameof(PageTree) }],
    Domains = [
        new UrlDomain { Host = "www.bikes.no", RootId = bikesHome },
        new UrlDomain { Host = "www.skis.no", RootId = skisHome },
    ],
    FallbackRootId = bikesHome,   // localhost, staging and any other host
}
https://www.bikes.no/products   the products page below bikesHome
https://www.skis.no/products    the products page below skisHome
http://localhost:5000/products  the fallback root's: bikesHome

Nothing about the domain is stored on a page, so the same database runs locally unchanged. db.GetUrl(page, absolute: true) adds the host of the page's domain, https://www.skis.no/products; without domains there is no host to add, and the URL stays relative.

A base path and trailing slashes#

new DefaultUrlManagerOptions { PrimaryBaseAddress = "/site", IncludeTrailingSlash = true }
/site/products/bikes/    a page URL starts with /site, and ends with a slash
/site/assets/…           a file URL starts with /site too

BaseAddressPages and BaseAddressAssets prefix only page URLs or only file URLs, after the primary base. A base with a scheme and host, such as https://cdn.example.com for files, makes those URLs absolute. Inbound URLs match either way.

URLs that survive a rename#

UrlFormat can put a page's id in its URL. With EncodedGuidAndAddress, the id finds the page and the address is only there to be read:

OnlyAddress (the default)    /products/bikes/trail-one
EncodedGuidAndAddress        /PSobnF9OcWCCk6S1xtfo-Q/products/bikes/trail-one

The second keeps working when the page is renamed or moved, and addresses never need a -2. The Relatude.DB manual lists all ten formats.

File URLs#

Stored files have URLs of their own, made by db.GetUrl(file) and db.GetUrl(file, adjustment):

/assets/{token}/trail-one.jpg     the file as it was uploaded
/assets/{token}/trail-one.webp    a variant, such as a resized image

The token says which file and which variant; the file name at the end is only for downloads. The options move them (AssetUrlRoot, BaseAddressAssets for a CDN), make them readable (PropertyPathFormat, AssetUrlFormat), or sign them so that an edited URL stops working (AssetUrlSignatureKey). See Asset URLs in the Relatude.DB manual. Chapter 7 tells how they are served.

All the options#

Option Default What it does
FallbackRootId the first domain's root the root for unknown hosts, answering /
Parents none: flat the relations from a page to its parent, for tree URLs
Domains none a root for each host
UrlFormat OnlyAddress the address, an id, or both
PrimaryBaseAddress none a prefix for every URL
BaseAddressPages, BaseAddressAssets none a prefix for page URLs or file URLs only
IncludeTrailingSlash off /products/ rather than /products
Scheme https the scheme of absolute URLs
MaxDepth 32 how far up the tree the manager walks, a guard against cycles
AssetUrlRoot, AssetUrlStyle, AssetUrlParamName /assets/, AssetRoot, asset where file URLs live
PropertyPathFormat, AssetUrlFormat Encrypted opaque or readable file URLs
AssetUrlSignatureKey none signs file URLs

URLs and the URL manager in the Relatude.DB manual covers each in depth, and how to write a URL manager of your own.

A link in an [HtmlProperty] survives the rename of the page it points to. The store keeps it by id and writes the current URL into every read, so editors paste ordinary URLs and never fix them later.

7. Answering a request#

The default#

app.MapRelatudeCMS() is all it takes. For a request that no other route answers, it asks the URL manager what the URL addresses, once, and answers by what it finds:

GET /products/bikes/trail-one
       │
       ▼
  the URL manager: what does this URL address?
       │
       ├── a page ──────────────────────►  rendered; a page nothing renders falls through
       ├── a stored file ───────────────►  sent, with range requests and caching
       ├── a page or file the visitor ──►  a login redirect, or 403 once signed in
       │   may not read
       └── nothing ─────────────────────►  the request goes on: other routes, MapFallback, or a 404

How a page is rendered is Part IV; how a file is sent is below.

With the article from chapter 6:

GET /hello-world                  Views/Shared/Article.cshtml, with the article as its model
GET /assets/{token}/photo.jpg     the photo
GET /no-such-page                 MapFallback, or a 404

Content is read as the visitor: anonymous, or the signed-in user (chapter 2). An unpublished page is answered as if it did not exist. A page or file that exists but the visitor may not read sends an anonymous visitor to the login page and answers a signed-in one with 403, or the scheme's access denied page; with LoginRedirect off, it too is answered as if it did not exist.

Which route wins#

Stored files#

Images, video and downloads are served by the same routes as pages, from the URL taken apart once. The answer comes from Relatude.DB's FileHandler:

@inject RelatudeCmsContext Ctx
<img src="@Ctx.GetUrl(Model.Picture)" alt="">
<img src="@Ctx.GetUrl(Model.Picture, new FileAdjustmentImage { Width = 400 })" alt="">
<video src="@Ctx.GetUrl(Model.Film)" controls></video>

Options#

A part of the site only#

MapRelatudeCMS takes a route pattern. Together with a base path for the URLs, the database answers below /site and the rest of the application keeps every other path:

builder.AddRelatudeDB(o => o.CreateUrlManager = _ => new DefaultUrlManager(new DefaultUrlManagerOptions { PrimaryBaseAddress = "/site" }));

app.MapRelatudeCMS("site/{**url}");

Finding content another way#

ContentResolver.Resolve is the one place a URL becomes content. Override it to find content another way, such as preview links or a legacy URL scheme. base.Resolve keeps the default, and Match(node) gives any node its template or code default, as a URL would:

using Relatude.CMS.Engine;
using Relatude.CMS.Models;
using Relatude.DB.CMS.Web.Rendering;

// ?preview=<page id> shows any page to editors; everything else resolves by URL
public sealed class PreviewContentResolver(IRelatudeDBAccessor db, ContentRenderingOptions options)
    : ContentResolver(db, options) {

    protected override ContentMatch? Resolve(HttpRequest request) {
        if (request.HttpContext.User.IsInRole("Editor")
            && Guid.TryParse(request.Query["preview"].ToString(), out var id)
            && Context(request.HttpContext).Admin.TryGet<IPage>(id, out var page)) {
            return Match(page);
        }
        return base.Resolve(request);
    }
}

// Program.cs, before or after AddRelatudeCMS()
builder.Services.AddSingleton<ContentResolver, PreviewContentResolver>();

ContentResolver is a singleton. Its answer for a request lives on the HttpContext, so an override keeps no state of its own. Context(http) is the request's RelatudeCmsContext: its Url is the URL taken apart, Node the node read as the visitor, and Db the store to read more with.

Another router beside it#

A host that also routes a legacy CMS maps its own transformer beside MapRelatudeCMS(). It answers only what the database does not address, so content, files included, always comes first and the two never answer the same URL:

using Microsoft.AspNetCore.Mvc.Routing;
using Relatude.DB.CMS.Web.Rendering;

public sealed class LegacyRouting(ContentResolver content, LegacyRouter legacy) : DynamicRouteValueTransformer {
    public override ValueTask<RouteValueDictionary> TransformAsync(HttpContext http, RouteValueDictionary values) {
        var ours = content.GetContent(http) != null || content.GetFile(http) != null;
        return ValueTask.FromResult(!ours && legacy.TryRoute(http, values) ? values : null!);   // null: no match
    }
}

// Program.cs
builder.AddRelatudeCMS().AddMvcRendering();
builder.Services.AddScoped<LegacyRouting>();
app.MapRelatudeCMS();
app.MapDynamicControllerRoute<LegacyRouting>("{**url}");

MvcContentRouteTransformer.TrySetRouteValues sets the route values for MVC targets, for a host that builds its routing from scratch instead.


Part IV — Rendering content#

8. What renders a page#

Two things can say what renders a page: code, once per page type, or a template the editor chose for the page. A site can use either or both. Code is the simplest, one line per type and nothing in the database. Templates are for the pages where editors choose between layouts.

GET /news/hello-world
       │
       ▼
  the page: an IArticle
       │
       ├── has the editor chosen a template? ───────────── yes ──►  what the template says:
       │                                                            a view, a controller, a Razor Page
       │ no
       ├── is there a template made for IArticle? ──────── yes ──►  that template
       │ no
       ├── is there a code default for IArticle? ────────── yes ──►  RenderRazorView<IArticle>():
       │                                                            Views/Shared/Article.cshtml
       │ no
       ▼
  not rendered: the request goes on to MapFallback, or a 404

Code decides: code defaults#

A code default says what renders every page of a type that has no template:

builder.AddRelatudeCMS()
    .AddMvcRendering().AddRazorPagesRendering()
    .AddRendering(o => o
        .RenderRazorView<IHomePage>("Home")             // Views/Shared/Home.cshtml
        .RenderRazorView<IArticle>()                    // no name: the type's, so Views/Shared/Article.cshtml
        .RenderMvcController<ISectionPage>("Section")   // SectionController.Index
        .RenderRazorPage<ILandingPage>("Landing")       // Pages/Landing.cshtml
        .RenderRazorView<IPage>("Default"));            // every other page
Call Renders the type with
RenderRazorView<IArticle>() the view Article: the type's name without its I
RenderRazorView<IArticle>("ArticleWide") the view ArticleWide
RenderRazorView<IProduct>(p => p.InStock ? "Product" : "SoldOut") a view chosen per page; null means not rendered
RenderMvcController<INewsList>("News") NewsController.Index; ("News", "List") for another action
RenderRazorPage<ILandingPage>("Landing") Pages/Landing.cshtml
RenderResult<IFeed>((http, feed) => Results.Text(…)) any IResult: JSON, a Blazor component, a file (chapter 11)
Render<ICampaign>(c => c.Live ? new RazorViewTarget("Live") : new RazorViewTarget("Teaser")) any target, chosen per page

A default covers its type and every type derived from it, and the closest type wins:

IPage ◄── IArticle ◄── INews        RenderRazorView<IPage>("Default")
                                    RenderRazorView<IArticle>()

an INews     → "Article"     IArticle is one step up, IPage two
an IProduct  → "Default"     only the IPage default covers it

Defining a default for the same type again replaces the first. Code defaults work for any content type, not only pages.

ViewPerType() is a shortcut for sites where every type has a view named after it: content nothing else renders gets that view, so an IFaqPage renders with FaqPage. It is off by default.

Editors decide: templates#

Templates need no setup:

So templates work without any code, when the site's pages all look alike per type. Where editors are meant to choose, make more than one template for the type. A page then has to choose, since none of them is closer than the others:

templates "article" and "article-wide", both for IArticle

an IArticle with Template = "article-wide"   → ArticleWide.cshtml
an IArticle with Template = "article"        → Article.cshtml
an IArticle with no template                 → error: both fit it equally, so the page has to choose

Saving a page without a template is refused in that case, so editors see the problem in the admin UI, not visitors on the site.

Both together#

A page's own template always wins, as long as it is for the page's type. Otherwise the closest template or code default wins, with two tie rules:

IPage ◄── IArticle ◄── INews        code:       RenderRazorView<IArticle>()      for IArticle
                                    templates:  "news"                           for INews
                                                "fallback"                       for no type

an INews     → template "news"        the closest
an IArticle  → RenderRazorView        the only one for IArticle itself
an IProduct  → template "fallback"    nothing closer; a template for no type is last
                                      (with RenderRazorView<IPage>("Default") added: "Default")

Deciding per request: selectors#

A selector looks at the request before anything else, for every page. The first selector to answer wins, and null passes the page on:

.AddRendering(o => o
    .Select((http, m) => http.Request.Query["view"] == "print" && m.Content is IArticle ? new RazorViewTarget("ArticlePrint") : null)
    .Select((http, m) => http.Request.Query["format"] == "json" ? new ResultTarget((_, c) => Results.Json(new { c.Content.Name })) : null))

Print views, previews, A/B tests and a theme per site all start here. A selector that needs a service takes it from http.RequestServices.

A selector is also how code overrides a template, since a page with a template otherwise renders by its kind, and how a plain ITemplate renders at all. It tells templates apart by their name or id:

.AddRendering(o => o
    // every page with the template "campaign": a Blazor component
    .Select((http, m) => m.Template?.Name == "Campaign"
        ? new ResultTarget((_, c) => new RazorComponentResult<CampaignPage>(new { Content = c.Content }))
        : null)
    // per page: a long article gets another view; null leaves the page to its template
    .Select((http, m) => m.Template?.Name == "Article" && m.Content is IArticle { Body.Length: > 20_000 } ? new RazorViewTarget("ArticleLong") : null))

A name is what editors see, and they may change it. A template that renders the same way for every page is sturdier as a kind of its own, below, since editors then see what renders it.

A template kind of your own#

A site adds a kind of template, which editors create in the admin UI like the built-in ones, by deriving from ITemplate and saying what renders it:

public interface IJsonTemplate : ITemplate {
    string Fields { get; set; }
}

.AddRendering(o => o.TemplateKind<IJsonTemplate>(t =>
    new ResultTarget((http, m) => Results.Json(new { m.Content.Id, m.Content.Name, fields = t.Fields }))))

The whole order#

the request
   │
   ├─ 1. selectors, in the order added ─────────────── the first answer wins
   │
   ├─ 2. the page has a template:
   │        the template's kind ─────────────────────── your own kinds first, then the built-in ones
   │
   ├─ 3. the page has no template:
   │        the code default for the closest type ───── an answer wins, null passes on
   │        ViewPerType(): the view named after the type
   │
   └─ 4. nothing: the page is not rendered

Step 2 and step 3 never both apply: a page with a template is rendered by it, a page without one by code. Which template a page gets, when it chose none, is settled before this, by the rules above.

Mistakes are caught early#

A wrong setup is found before a visitor sees it: when an editor saves a page, and in the log when the site starts and whenever a template changes.

Problem Saving the page The log at start A request
a page's template is not for its type refused error
a page without a template, and two templates fit its type equally refused a note error
two code defaults fit a type equally (a type with two base types) refused error error
a plain template, and no selector renders it error, when there is no selector error
a template or code default names a view, action or Razor Page that does not exist error the framework's error
a template lists a type that is not in the datamodel warning

A code default decided per page names nothing up front, so the log cannot check it.

The targets#

What every decision above ends in: a target, which names the code that renders the page.

Target Needs What renders
RazorViewTarget("Article") AddMvcRendering() the view, with the page as its model and no controller of your own
MvcControllerTarget("News", "Index") AddMvcRendering() the action, with the page available through [FromContent]
RazorPageTarget("/Landing") AddRazorPagesRendering() the Razor Page, with the page available through [FromContent]
ResultTarget((http, match) => IResult) nothing more the IResult the code returns

The Render… shorthands and the template kinds make these for you; a selector returns one itself. A target whose rendering system was not added is not rendered.

9. MVC views and controllers#

Views#

A view renders the page directly, with the page as its model and no controller of your own. It is found the MVC way: by name in Views/Shared, or by a full path such as ~/Views/Pages/Article.cshtml.

@* Views/Shared/Article.cshtml, from RenderRazorView<IArticle>() or a view template *@
@using MySite.Models
@model IArticle

<article>
    <h1>@Model.Name</h1>
    <p class="intro">@Model.Intro</p>
    @Html.Raw(Model.Body)
</article>

Controllers#

A controller is for pages that need more than a view: query parameters, a model built from several queries, a form. The action gets the page through [FromContent]; other parameters bind as usual.

using Microsoft.AspNetCore.Mvc;
using MySite.Models;
using Relatude.DB.CMS.Web;
using Relatude.DB.CMS.Web.Rendering;

// from RenderMvcController<INewsList>("News") or a controller template
public class NewsController(RelatudeCmsContext ctx) : Controller {
    public IActionResult Index([FromContent] INewsList list, int page = 0) {
        var articles = ctx.Db.Query<IArticle>().Page(page, list.PageSize).Execute();
        return View(new NewsListModel(list, [.. articles], articles.TotalCount));
    }
}

public record NewsListModel(INewsList List, IArticle[] Articles, int TotalCount);

The controller is reached through the page's URL, not its own, so it has no [Route] and no [ApiController]: those make an action unreachable this way.

10. Razor Pages#

A Razor Page is named by its path below Pages/. Its handler gets the page through [FromContent], and the @page directive needs no route of its own.

// Pages/Landing.cshtml.cs, from RenderRazorPage<ILandingPage>("Landing") or a Razor Page template
using Microsoft.AspNetCore.Mvc.RazorPages;
using MySite.Models;
using Relatude.DB.CMS.Web.Rendering;

public class LandingModel : PageModel {
    public ILandingPage? Landing { get; private set; }

    public void OnGet([FromContent] ILandingPage? page) => Landing = page;
}
@* Pages/Landing.cshtml *@
@page
@model LandingModel

<h1>@Model.Landing?.Headline</h1>

11. Any IResult: Blazor, JSON and the rest#

A ResultTarget returns any IResult, so it needs neither MVC nor Razor Pages: JSON for a headless client, a file, a template engine of your own, or a Blazor component rendered on the server.

builder.AddRelatudeCMS()
    .AddRendering(o => o
        // JSON for a headless client: a projection, never the node itself
        .RenderResult<IApiPage>((http, page) => Results.Json(new { page.Id, page.Name }))
        // RSS, through a class of the site's own
        .RenderResult<IFeedPage>((http, feed) => Results.Text(FeedWriter.Rss(feed), "application/rss+xml")));

Serialize a projection, never the node itself. A node's relations are enumerable, so a serializer handed the node would walk into every related node, and on through theirs.

Blazor#

A Blazor component renders a page through RazorComponentResult. The component is a plain one, with no @page, and receives the page through a parameter named Content:

@* Components/ArticlePage.razor *@
@using MySite.Models
<PageTitle>@Content.Name</PageTitle>
<h1>@Content.Name</h1>
@((MarkupString)Content.Body)
<LikeButton ArticleId="@Content.Id" @rendermode="InteractiveServer" />

@code {
    [Parameter] public IArticle Content { get; set; } = default!;
}
using Microsoft.AspNetCore.Http.HttpResults;   // RazorComponentResult

builder.Services.AddRazorComponents().AddInteractiveServerComponents();

builder.AddRelatudeCMS()
    .AddRendering(o => o
        .RenderResult<IArticle>((http, article) => new RazorComponentResult<ArticlePage>(new { Content = article })));

app.UseAntiforgery();                           // MapRazorComponents needs it, even for static pages
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.MapRelatudeCMS();

Two rules keep this working, now and when Blazor becomes a rendering system of its own, with a template kind and a RenderRazorComponent shorthand beside the view, controller and Razor Page ones:

RazorComponentResult renders the component as a whole document, outside the host's App.razor, Routes.razor and layout, so the component, or a layout component it wraps itself in, has to produce the <html> and <head> it needs, with the Blazor script when it has interactive parts. Rendering inside the host's Routes and MainLayout is what the rendering system of its own will add; a component written to these rules will not change.

One more thing to know: a @page "/" in the host's own Blazor pages hides the CMS home page, since a literal route beats the content routes' catch-all, and so does any @page whose route matches a content address.

12. Reading the content#

The page is found once per request and shared with everything that asks for it:

Where How
an action or a page handler [FromContent] IArticle article, or [FromContent] ContentMatch match for the page with its template and culture. null when the request has no page, or one of another type.
a view, a layout, middleware: anywhere with an HttpContext Context.GetContent<IArticle>(), or Context.GetContentMatch()
a service inject RelatudeCmsContext and read Content, or Render for the target too; User, Db and Url are beside them

A shared layout reads the page for its title, whichever view renders the body:

@* Views/Shared/_Layout.cshtml *@
@using Relatude.CMS.Models
@using Relatude.DB.CMS.Web.Rendering
@{
    var page = Context.GetContent<IPage>();
}
<title>@(page?.MetaTitle ?? "My site")</title>
<meta name="description" content="@page?.MetaDescription">
ContentMatch Holds
Content the page, as IItem
Template its template, chosen by the editor or found by its type; null when it has none
TemplateByType true when the template was found by type rather than chosen by the editor
TypeDefault the target its code default gave, when it has no template
CultureId, CultureCode the culture the URL matched, such as nb-NO; empty for a URL without one

A page bound with [FromContent] is not validated by MVC. Validation would follow every relation of the page and read every related node.


Part V — Site services#

13. CMS settings#

CmsSettings (namespace Relatude.CMS.Settings) is a Relatude.DB custom settings set: one per database, shown as a CMS tab beside the database's settings in the admin UI, and read from code:

var settings = db.Settings.Get<CmsSettings>();
if (settings.Analytics.Enabled) { … }

Values are layered like every custom setting, each layer overriding the one before:

  1. the defaults in code;
  2. relatude.settings/cms.json, shared and committed with the application;
  3. what the admin UI saved on this server;
  4. RelatudeDB:CustomSettings:cms in appsettings or environment variables.
{
  "RelatudeDB": {
    "CustomSettings": {
      "cms": { "Analytics": { "Enabled": true, "ExcludedPaths": "/api/, /preview" } }
    }
  }
}

Every CMS setting applies live, without a restart. Further CMS settings go into the same class as new properties and groups in CmsSettingsDefinition, rather than into new settings sets.

Setting Default Admin UI label
Analytics.Enabled false Record page views
Analytics.LogBots true Record bots
Analytics.LogLocation true Record location
Analytics.LogQueryString false Record query strings
Analytics.ExcludedPaths none Paths left out, separated by commas
Analytics.InjectBotCheck true Add the bot check
Analytics.InlineBotCheck true Write it into the page
Analytics.LookUpAddresses true Look up visitors
Analytics.LookupDelaySeconds 5 Wait before looking up (0 to 300 seconds)
Modules.Disabled none Modules turned off, by name, separated by commas (chapter 18)
InterfacePlugins none Interface plugins: a name, an address, and whether it loads

14. Page-view analytics#

Analytics records every page view in the database, without cookies and without storing addresses. It is off until CMS › Analytics › Record page views is turned on, and it needs only AddRelatudeCMS() and UseRelatudeCMS().

app.UseForwardedHeaders();        // behind a proxy: first, so the visitor's own address is used
app.UseResponseCompression();     // if at all: before the CMS, which only rewrites uncompressed HTML
app.UseRelatudeDB();
app.UseRelatudeCMS();             // after UseRelatudeDB, before the site's pages and static files

What counts as a page view#

A page view is a GET answered 200 with text/html. These are never page views:

Nothing happens while the visitor waits#

The middleware only queues the page view (PageViewQueue, room for 50,000; views beyond that are dropped and counted in Dropped). AnalyticsWorker, a hosted service, waits LookupDelaySeconds so the browser's bot check has time to report, looks up the visitors of a batch of up to 1,000 at once with db.Services.IPLookup, and records the entries. Each address and user agent is looked up once; settled bot or human verdicts are kept for 10 minutes. A failed lookup records the page views without it and warns at most once per 10 minutes.

The bot check#

When InjectBotCheck is on and the database has an IP lookup provider, the bot check script is added to every HTML page just before its last </body>, as HtmlRewriter finds it. Inline (about 500 bytes) by default; turn InlineBotCheck off under a Content-Security-Policy that forbids inline script, and the page loads /-info/bot.js instead. Only an uncompressed UTF-8 HTML 200 of at most 16 MB is held to do it; everything else streams through untouched.

The visitor is a daily visitor hash: SHA-256 of the address, user agent and Accept-Language with a salt of 32 random bytes per UTC day. The salt is held in memory only and never written, so a hash cannot be traced back or linked across days. A restart starts a new salt, so a visitor seen on both sides of it counts twice that day. The referrer is kept as its host only, and the query string is left out unless LogQueryString is on.

The log#

Page views go to the custom log cms-pageviews, Page views under Logs in the admin UI, with daily files kept for 400 days (2,000 MB at most) and statistics for the graphs.

Column Type Statistics
path, visitor, city text estimated unique count
host, referrer, visitorType, botName, country, browser, os, device, language text unique count with values
countryCode, region text none
position geo coordinate heatmap
durationMs number count, sum, average, min, max

visitorType is human, bot or unknown. Location columns are written only when LogLocation is on and the country is known; browser, operating system and device only for visitors who are not bots.

The log belongs to the database once it is made. It is created when it is missing, and after that changes made in the admin UI (retention, columns taken out, statistics) are kept. The code only adds columns: each column in PageViewLog carries the DefinitionVersion that introduced it as its Since, and the database's storage remembers the version its log has (cms/pageviews-log.json). To add a column, give it the next version and raise PageViewLog.DefinitionVersion.

Testing and replacing the database side#

Everything analytics reads from or writes to the database goes through IAnalyticsHost: the settings, the custom logs, the storage, the IP lookups and the reserved paths. The default is RelatudeAnalyticsHost, on the default database. Register another implementation before AddRelatudeCMS() to replace it; the CMS's own tests use a fake.

15. HTML utilities#

Relatude.CMS.Utils holds a forward-only HTML scanner and a rewriter built on it. Neither builds a DOM or allocates per token, so they are cheap enough to run on every response; analytics uses the rewriter to place the bot check.

Reading#

using Relatude.CMS.Utils;

var images = 0;
var reader = new HtmlReader(html);
while (reader.Read()) {
    if (reader.Kind == HtmlTokenKind.StartTag && reader.Name.Equals("img", StringComparison.OrdinalIgnoreCase)) images++;
}

HtmlReader is a ref struct over a ReadOnlySpan<char>. Each token has a Kind (Text, StartTag, EndTag, Comment, Doctype, CData, ProcessingInstruction), its Name, Raw source text, Start and End offsets, IsSelfClosing, CommentData, and, for start tags, Attributes, which foreach enumerates as HtmlAttributes with Name, Value (still entity-encoded) and Quote. The bodies of script, style, textarea and title are read as text.

Rewriting#

HtmlRewriter.Rewrite copies HTML through and splices in only what a handler changes. The handler can rewrite attribute values, insert markup before a tag, and replace text.

// adds a campaign to every external link in an article body
sealed class CampaignLinks(string campaign) : IHtmlRewriteHandler {
    public string? RewriteAttribute(ReadOnlySpan<char> tag, ReadOnlySpan<char> attribute, ReadOnlySpan<char> value) =>
        tag.Equals("a", StringComparison.OrdinalIgnoreCase)
        && attribute.Equals("href", StringComparison.OrdinalIgnoreCase)
        && value.StartsWith("https://")
            ? string.Concat(value, value.Contains('?') ? "&amp;" : "?", "utm_campaign=", campaign)
            : null;                                    // null leaves the value as it is

    public string? InsertBefore(ReadOnlySpan<char> tag, bool isEndTag) => null;
}

var html = HtmlRewriter.Rewrite(article.Body, new CampaignLinks("autumn"));
Member Does
IHtmlRewriteHandler.RewriteAttribute a new value for an attribute, or null
IHtmlRewriteHandler.TryWriteAttribute the same, written straight into an HtmlValueWriter without allocating (optional)
IHtmlRewriteHandler.InsertBefore markup to insert before a start or end tag, or null
IHtmlRewriteHandler.OnText, RewriteText see or replace text outside script and style (optional)
HtmlRewriteOptions RewriteInsideConditionalComments (default on) and MaxConditionalDepth (4), for Outlook's conditional comments in email
HtmlRewriter.ToText(html) the visible text: tags stripped, script and style dropped, whitespace collapsed, entities decoded
HtmlEntities.Encode, Decode escape the five HTML special characters; decode numeric and common named entities

HtmlRewriter.ToText suits search snippets and meta descriptions:

var text = HtmlRewriter.ToText(article.Body);
article.MetaDescription = text.Length > 160 ? text[..157] + "…" : text;

16. Donations#

IDonation (namespace Relatude.CMS.Models.Donations) is a generic donation node with the list and detail projection behind the WAF.Client Donations admin module. It does not extend IItem, so a site that only takes donations does not pull in the content model.

Group Properties
identity Id, Name, Reference (shared with the payment provider), DonationTypeId, CreatedUtc
the gift Amount (whole currency units), GiftType, Frequency, PaymentMethod, Status, Anonymous, Consent, TermsAccepted
payment KID (AvtaleGiro/OCR), TransactionId (card), AgreementId (recurring, such as Vipps)
the donor DonorFirstName, DonorLastName, DonorEmail, DonorPhone, DonorAddress, DonorPostalCode, DonorCity
business gifts CompanyName, OrganizationNumber, CorporateInvoiceEmail, OrderReference, BillingAddress, BillingPostalCode, BillingCity
the form FormId (the form's content id), FormName (its name when the donation was made)
attribution HttpAddress, UtmSource, UtmMedium, UtmCampaign, GaClientId, GaSessionId, FbcCookie, FbpCookie, UserAgent

The admin module filters the text fields below case-sensitively, so store exactly these values:

Field Values
GiftType private, business
Frequency onetime, recurring
PaymentMethod card, avtalegiro, vipps, vippsrecurring, invoice
Status pending, completed, failed, cancelled, refunded

A site extends the model with its own interface, and registers both namespaces as datamodel sources (chapter 2). A member may be declared in only one interface of the hierarchy.

namespace MySite.Models;

public interface IMyDonation : Relatude.CMS.Models.Donations.IDonation {
    string Campaign { get; set; }
}

The admin contract#

The admin controller stays in the host, since it derives from the host framework's admin API base (such as WAF's BaseAdminAPIController), which this package does not reference. Routed as wafapi/Donations/{action}, it looks like this:

using Relatude.CMS.Models.Donations.Admin;

[HttpPost]
public DonationListResult List(int pageSize = 25, int pageIndex = 0, string? sortBy = null, bool sortDesc = true,
    string? categories = null, string? statuses = null, string? paymentMethods = null, string? search = null) {
    _ = Context.Session;                               // WAF's admin gate: throws (403) without a session
    var query = _cx.Database.Query<IMyDonation>()      // WhereIn(d => d.Status/…) for non-empty filters
        .OrderBy(DonationAdminProjection.SortExpression<IMyDonation>(sortBy), sortDesc);
    var page = query.Page(pageIndex, pageSize).Execute();
    return new DonationListResult { Rows = page.Values.Select(d => DonationAdminProjection.ToRow(d)).ToList(), TotalCount = page.TotalCount };
}

Get(id) is TryGet<IMyDonation> followed by DonationAdminProjection.ToDetail(d, formGuid: …), where the host resolves the originating form's content guid from FormId. The host also copies the form's name onto FormName when the donation is created, so the list can sort and search by it; a later rename of the form does not change existing donations.

DonationAdminProjection Does
SortExpression<T>(sortBy) the sort for a column key (Amount, Status, Name, Reference, DonorFirstName, DonorLastName, DonorEmail, PaymentMethod, GiftType, FormName); CreatedUtc otherwise
ToRow(d), ToDetail(d, …, formGuid) the list row and the detail view
DisplayName(d) the company, "Anonymous donor", the donor's name or "(no name)"
MatchesCategory(d, categories) onetime, recurring or business
MatchesSearch(d, term) reference, donor names, email, company and form name, ignoring case
Split(csv) a comma-separated filter as an array

Category and free-text search have no query form. When either is active, the host executes the ordered query, filters with MatchesCategory and MatchesSearch, and then pages.

17. The CMS API#

The CMS API is the NodeStore over HTTP, for a CMS interface running in the browser and for the plugins built into it. It is built the way the Relatude.DB admin UI is: one command endpoint that dispatches on a command's type, and one event stream for everything the server starts. Every request carries a bearer token that names a user node, and the command runs as that user, so queries return only what the user may read.

builder.AddRelatudeCMS()
    .AddApi(o => {
        // the CMS's users have no password of their own: the site says how a sign-in is checked
        o.VerifyLogin = (login, cms) => Task.FromResult(Members.Verify(cms.Admin, login.UserName, login.Password)?.Id);
    });

var app = builder.Build();
app.MapRelatudeCmsApi();   // before MapRelatudeCMS(), whose catch-all route would be tried first
app.MapRelatudeCmsUI();    // the interface itself, at /relatude.cms/ (below)
app.MapRelatudeCMS();

Routes#

Route Does
POST {Path}/command runs { type, payload, runId? } and answers its result as JSON
GET {Path}/stream the event stream (server-sent events)
POST {Path}/upload?id=&property=&name= stores the request body in a file property
POST {Path}/auth/login a token for { userName, password }, as VerifyLogin checks them
POST {Path}/auth/session a token for the user the site's own sign-in (its cookie) names
POST {Path}/auth/refresh a new token for a valid one

Path is {UIPath}/api, so /relatude.cms/api, unless CmsApiOptions.Path says otherwise. A failure answers { error }: 400 for a request the API cannot use (a payload, a value or a query that does not fit), 401 without a valid token, 403 when Authorize says no, 404 for a node that does not exist, 500 for anything else. A command sent with a runId can be stopped with command-cancel and that id; it then answers 499.

Tokens and who a request runs as#

DataProtectionCmsApiTokens, the default ICmsApiTokens, seals a token with ASP.NET Core data protection: it cannot be forged or read, and runs out after TokenLifetime (12 hours). A client renews it with auth/refresh before then. A token of a user deleted since is refused. The tokens outlive a restart only when the data protection key ring does, so a server that keeps no keys signs everyone out when it restarts. Replace the registration of ICmsApiTokens to use tokens of another kind.

The token gives the user, and the payload's context gives the rest of the query context:

context Means
culture read and write in this culture, such as nb-NO
cultureFallback fall back to another culture for nodes with no version in this one
edit edit view: unpublished nodes too, and edit-view access applied beside read access
hidden nodes of hidden types too

Reads are filtered by what the user may read, everywhere: a query applies it, and so do get and exists, which go through a query because a read by id ignores read access. Writes are open to every signed-in user for now. CmsApiOptions.Authorize is called before every command with the caller and the command type, and is the place for access rules until the engine checks writes itself. AllowAnonymous serves requests without a token, as the anonymous visitor, and only commands that read.

Commands#

Command Payload Answers
whoami { userId, anonymous, name, email, groups }
schema the node types with their own and inherited properties, and the cultures
query { query, parameters } the result, by what the query ends in (below)
get { ids } { items }: the nodes the user may read, in the order asked for
exists, count { id }, { type? } as the user reads
get-related { id, property } { items }: the related nodes, in the relation's order
get-url, file-url { id }, { id, property, adjustment? } a URL; an adjustment asks for a resized or converted image
insert { type, id?, values, ifNotExists? } the stored node
update { id, values, force?, ifExists? } the node; only the given properties are written, null resets one
upsert { type, id, values } the node
delete { ids, ifExists? } { deleted }
relate, unrelate, set-relation, clear-relation { id, property, to } true
move-relation, set-relation-order { id, property, items, position \| offset \| before \| after } true
reset-property, add-to-property, multiply-property { id, property, value? } the node
change-type, update-meta, update-address, update-display-name { id, … } the node
file-delete { id, property } true
transaction { actions: [{ op, … }] } { results }: each action's answer, in order

A write is raw transaction data run by NodeStore.Execute, so the store's transaction plugins run just as they do for a write from C#. transaction runs all its actions in one transaction: all of them happen, or none, and an action may use a node an earlier one inserts. Types, properties and relation ends are named by CodeName or id, and CodeNames match exactly, as in the query language. Locks, revert windows, maintenance and jobs are not commands: they belong to the server's operator.

Nodes and values on the wire#

A node is one object: its properties by CodeName, and the system fields with a $ prefix that no C# name can have.

{
  "$id": "…", "$type": "IArticle", "$displayName": "Five trails worth the climb",
  "$createdUtc": "2026-10-01T08:00:00.0000000Z", "$changedUtc": "…",
  "Title": "Five trails worth the climb", "Price": 149.5, "Tags": ["mtb"],
  "Picture": { "name": "five-trails.svg", "size": 1834, "contentType": "image/svg+xml", "url": "/…" },
  "Parent": { "$id": "…", "$type": "ISectionPage", "Name": "News" }
}

A relation end is there only when the query included it. Dates are ISO 8601 text in UTC, a TimeSpan its invariant c text, a coordinate { latitude, longitude }. HTML comes back with the store's internal links as URLs. Values sent in are parsed strictly by the property's type: a value that does not fit is refused, rather than stored as the type's default. An enum takes its number or its name.

A query result has a kind: nodes (items, totalCount, pageIndex, pageSize, …), values (SelectId), objects (Select), search (hits with node, sample and score), facets (items and facets), pivot (result, for Pivot and GroupBy), path (ShortestPath) or value (Count, Sum). The parameters are typed - string, int, long, double, float, decimal, bool, datetime, datetimeoffset, timespan, guid, geo, or an array such as guid[] - so a decimal property is compared with a decimal and a date with a DateTime, which the literals of the query language cannot all say.

The event stream#

The stream needs the same token. It sends connected with the connection's id first, ping every 10 seconds, which a client also uses to tell a dead connection from a quiet one, and unauthorized when the token runs out. After a command changes nodes it sends nodes-changed, { changes: [{ id, type, kind }] }, to each connection, with only the nodes that connection's user may read; deletions always. Changes made outside the API, by the site or the admin UI, are not sent.

Modules#

A module adds commands of its own the way the built-in ones are registered:

var commands = app.Services.GetRequiredService<CmsApiCommands>();
commands.Register("shop-stock", ctx => {
    var p = ctx.Payload<StockRequest>();
    return ctx.Db.Query<IProduct>().Where(x => x.Sku == p.Sku).Execute().Select(x => x.Stock);
}, readOnly: true);

ctx.Db is the store as the caller, ctx.Root the whole store for checks the server makes on its own behalf, and ctx.Changed(id, typeId, kind) puts a node in the nodes-changed event once the command succeeds. CmsApiEvents.Broadcast(name, payload) sends an event of the module's own.

From TypeScript#

@relatude/sdk (Relatude.UI/packages/sdk) carries the client: a channel to the two routes, a TokenSession, and RelatudeStore, whose methods are the NodeStore's, with a query builder typed by the model's interfaces. The CMS interface in Relatude.UI is built on it, and a plugin gets the same store as cms.store. Chapter 19 covers the SDK, and chapter 20 the plugins.

import { db } from '@relatude/sdk';

const session = new db.TokenSession('/relatude.cms/api');
await session.signIn('ann@velo.example', 'ann');
const store = db.createStore({ baseUrl: '/relatude.cms/api', token: () => session.token() });

const Product = db.nodeType<IProductPage>('IProductPage');
const cheap = await store.query(Product).where(n => n.Price.lte(500)).orderBy(n => n.Name).execute();

The CMS interface#

The interface editors sign in to is not in the package. Its releases are published to services.relatude.com, and MapRelatudeCmsUI() serves the newest one from the site's own address:

Route Does
GET {ui}/ asks which release is the newest, and answers its page
GET {ui} redirects to {ui}/, the query string kept (?safemode=1)
GET {ui}/r/{version}/… the release's scripts, styles and other files

Each time the page is asked for, the installation asks services.relatude.com, server to server, which release is the newest: POST {services}/api/cmsui/latest with { apiKey, cmsVersion, installationKey, uiVersion }, uiVersion being the release it holds. Only a release it does not hold is downloaded, from POST {services}/api/cmsui/release, as a zip checked against the SHA-256 the service named. The installation answers its index.html, never cached, with two things put first in its head: <base href="{ui}/r/{version}/">, so every relative URL in the release, its import map's included, points at that release's files, and window.__RELATUDE_CMS__ = { api, uiPath, version, cmsVersion }, which tells the interface where its API is. The files are served from memory and cached by the browser as immutable, since the version is in their path. CmsUiServer keeps the last three releases, so a page loaded before a new release keeps working, and the zip of the one it serves in the database's storage (cms/ui/), so a restart does not download it again. That copy is what is served while the service cannot be reached. A refusal, or no release published, is never answered with it.

The CMS takes a license with the feature cms. The server's ApiKey setting (the admin UI's Settings, or RelatudeDB:ApiKey in appsettings) goes with every request, to the ServicesServerUrl setting (https://services.relatude.com) unless CmsApiOptions.ServicesUrl is set. When there is nothing to serve, a short HTML page says why: 503 with no API key, 403 and the service's reason when it refuses the key, 404 when no release is published, 502 when the service cannot be reached and no copy is kept. The service counts each opening and download, per release and per installation.

The path is set as /relatude.db's is. The server setting CMS > Hosting > CMS path (CmsServerSettings.UIUrlPath, file key cms-server), read when the application starts, wins over CmsApiOptions.UIPath. The API moves with it, to {ui}/api, unless CmsApiOptions.Path is set.

A release has a version of its own, apart from the package's, and every installation serves the newest release whatever CMS version it runs. Relatude.UI/publish.bat builds the interface, asks the service for its latest release, suggests the next version and publishes the build to services.relatude.com/publishcmsui with the publish key from Relatude.DB.Secrets. Releases come from ICmsUiSource, ServicesCmsUiSource by default; register another to serve them from elsewhere.

18. Modules#

A module is everything one feature adds to a site, described in one class and shipped in one assembly, usually a NuGet package. The site adds it with one line, and the module brings the rest: its model types, transaction plugins, jobs, settings for each database and for the server, seed data, commands of the CMS API, routes, rendering, views, an interface plugin, custom logs and user groups, and what it needs of other modules and of the license.

The module is the package. The plugins it brings are smaller parts that each hook into one thing: a transaction plugin into the store's writes, an interface plugin into the CMS interface (chapter 20). An interface plugin can also come without a module, listed in the CMS settings.

EventsModule/, beside Website2, is a worked example of every part: three modules in one assembly (venues, events at them, and SMS reminders, which need the license). Its README says how to see each part working on Website2.

A module#

A module is a class deriving from CmsModule (namespace Relatude.DB.CMS.Modules). It has a name, and Configure says what it is made of:

public sealed class EventsModule : CmsModule {
    public override string Name => "events";
    public override string Title => "Events";
    public override string Description => "Events at venues, with registration and a calendar feed.";

    public static readonly Guid Managers = new("6549f757-c808-4c73-bac2-ea96b826ef49");

    // an option of the module's, which the host sets when it adds it
    public Guid? ParentPageId { get; init; }

    public override void Configure(CmsModuleBuilder module) {
        module.Services.AddSingleton<EventCalendar>();
        module
            .DependsOn<VenuesModule>()
            .Datamodel(dm => dm.AddNamespace<IEventPage>())
            .TransactionPlugin<EventPagePlugin>()
            .Job("events.close-past", "Marks the events that have ended as ended.", closePast)
            .Settings<EventSettingsDefinition>()
            .Settings<EventServerSettingsDefinition>()
            .Seed(1, "Three events at the venues", EventSeed.Events)
            .Seed(2, "A first registration", EventSeed.Registration)
            .Seed(3, "Events listed under the site's page", c => EventSeed.ListUnder(c, ParentPageId))
            .UserGroup(Managers, "Event managers")
            .Log(EventActivity.Definition())
            .OnStoreOpened(c => …)
            .Command("events.upcoming", EventCommands.Upcoming, readOnly: true)
            .Command("events.cancel", EventCommands.Cancel)
            .Endpoints(EventEndpoints.Map)
            .Rendering(o => o.RenderRazorView<IEventPage>("EventPage"))
            .Views()
            .InterfacePlugin();
    }
}
Builder method What it adds When
Services the module's own services at once
DependsOn<T>() a module it builds on: ordered after it, active only while it is the site's start
RequiresLicense("feature") a feature the installation's license must carry checked as the store opens
AppliesTo(test) which databases it is part of; every one with the CMS content model unless set each datamodel
Datamodel(dm => …) model types each datamodel, after the host's
OnStoreInit(c => …), TransactionPlugin<T>(), Job(…) transaction plugins, task runners, jobs each store, before it opens
Settings<TDefinition>() a custom settings set, per database or per server at once
Seed(n, description, c => …) seed data in numbered steps each store, once open
UserGroup(id, name), Log(definition) a user group, a custom log, made where missing each store, once open
OnStoreOpened(c => …) start-up work each store, once open, after the seed data
Command(type, ctx => …) a command of the CMS API registered with the API
Endpoints(e => …) routes of its own mapped by MapRelatudeCMS()
Rendering(o => …), Views() code defaults and template kinds; the views compiled into the module at once
InterfacePlugin(entry, folder) its interface plugin served by the CMS API

The work done on a store gets a CmsModuleContext: the store as the master admin (Store), the application's services (Services) and the module itself (Module).

Adding modules#

builder.AddRelatudeCMS()
    .AddApi()
    .AddModule<VenuesModule>()
    .AddModule(new EventsModule { ParentPageId = SiteSeeder.HomeId })
    .AddModule<EventRemindersModule>();

AddModule runs Configure at once: the module's services, settings and rendering are added then, and the rest waits for the start of each database. A module added twice is added once, so a module can be added by a package and by the host without harm. The name is letters, digits and -, starting with a letter, and must be the module's own.

A module builds on another with DependsOn<T>(), and the other must be added too, before or after. The modules are ordered with their dependencies first, otherwise in the order added. A missing dependency, or modules that build on each other in a circle, stop the site at UseRelatudeCMS() with a message naming them, before any database opens.

Modules are added in code, never discovered by scanning assemblies: installing a .NET module takes a build and a restart anyway, and Program.cs then says what runs, and in which order.

The database's start#

The modules take part in every database's start through three services Relatude.DB calls (chapter 2), which AddRelatudeCMS() registers. No setup call of the host's can lose them.

  1. The datamodel (IDatamodelInitializer). After the host's OnDatamodelInit, every module that applies adds its model types, dependencies first. A module applies to a database whose datamodel has the CMS content model (IItem), unless AppliesTo says otherwise, and never to one without a module it builds on.
  2. The store, before it opens (IStoreInitializer). Every module that is part of the database registers its transaction plugins, task runners and jobs. This is done whether or not the module is active, so its data stays guarded while it is turned off.
  3. Once the store is open (IStoreOpenHandler), on a background thread, so the opening does not wait. For every module that is part of the database and active, dependencies first: its user groups and custom logs are made where missing, its seed steps not run yet are run, and its start-up work is done.

A module is part of a database only when its model types went into that database's datamodel, which is also what CmsModules.IsPartOf(name, store) answers.

Seed data#

Seed data is numbered steps, each run once per database, in order:

.Seed(1, "Two venues", c => {
    if (c.Store.TryGet<IVenue>(Workshop, out _)) return;            // a step finds what an earlier run made
    c.Store.CreateAndInsert<IVenue>(v => { v.Name = "The workshop"; v.Seats = 24; }, Workshop);
})

CmsModules.SeedVersionOf(name, store) answers the last step a database ran.

Transaction plugins and jobs#

TransactionPlugin<T>() registers a NodeTransactionPlugin made with the application's services. A write reaches it in one of two forms, and a rule about values has to check both:

public override void OnBeforePropertyAction(NodeKey key, Guid[] propertyIds, NodePropertyOperation operation,
        object[]? values, Transaction transaction, PropertyHelper<IEventPage> helper) {
    // the values written, by property id; the rest as stored
    var stored = helper.GetNodes([key.Guid]).FirstOrDefault();
    …
}

Job(name, description, run) registers a job on each store, which the admin UI's scheduler can run at set times or by hand (Tasks › Scheduler). The schedule is the administrator's: the module only says what the job does. While the module is not active, a run does nothing and reports why.

Settings#

Settings<TDefinition>() registers a custom settings set: a tab of its own in the admin UI, beside each database's settings or the server's, as the definition's Scope says. It is read like any other:

var settings = db.Settings.TryGet<EventSettings>(out var s) ? s : new();          // per database
var server = RelatudeDBRuntime.Server.CustomSettings.Get<EventServerSettings>();  // per server

TryGet keeps code working where the set is not registered, in a test, say.

Commands#

Command(type, handler, readOnly) adds a command to the CMS API (chapter 17). It runs as the signed-in user like the API's own: ctx.Db reads with the user's access, ctx.Root is the whole store, ctx.Payload<T>() reads the payload, and ctx.IsInGroup(groupId) says whether the user is a member of a group, directly.

public static object? Cancel(CmsApiContext ctx) {
    if (!ctx.IsInGroup(EventsModule.Managers)) throw new CmsApiException(403, "Only event managers may cancel an event.");
    var id = ctx.Payload<CancelPayload>().Id;
    var e = ctx.Db.Query<IEventPage>().Where(id).Execute().FirstOrDefault() ?? throw new CmsApiException(404, "There is no such event.");
    e.Status = EventStatus.Cancelled;
    ctx.Db.Update(e);
    ctx.Changed(e.Id, e.Meta.NodeTypeId, CmsApiChangeKind.Updated);   // tells the open CMS interfaces
    return new { cancelled = true };
}

Commands are named after their module, events.cancel, and adding one named otherwise throws, so a module can never replace another module's command or one of the API's own. While the module is not active its commands answer 403 with the reason. The CMS interface sends one with cms.store.send("events.cancel", { id }).

Routes#

Endpoints(e => …) maps routes of the module's own, for visitors rather than editors: a feed, a form post, a webhook. MapRelatudeCMS() maps them, in a group of the module's. While the module is not active they are left out of routing, so a request falls through to whatever else answers it, or a 404.

endpoints.MapGet("/events.ics", (HttpContext http, RelatudeCmsContext cms, EventCalendar calendar) => …);
endpoints.MapPost("/events/{id:guid}/register", register).DisableAntiforgery();

A route reads as the visitor through RelatudeCmsContext.Db, and writes as the master admin through .Admin, after checking what the visitor may do.

Rendering and views#

Rendering(o => …) adds to the site's one set of rendering options (chapter 8): code defaults for the module's types, selectors, template kinds. Views() makes the views, controllers and Razor Pages compiled into the module's assembly part of the application, so a module built as a Razor class library (Microsoft.NET.Sdk.Razor, AddRazorSupportForMvc) brings the views its code defaults name. The site's own _ViewStart gives them the site's layout.

A module's pages get their URLs from the site's URL manager (chapter 6). On a site whose URLs follow a tree, the module's types need a parent there: the events module has a relation for it, EventParent, and Website2 names it among its URL parents, so an event under the home page is at /autumn-trail-day.

The interface plugin#

InterfacePlugin() gives the module a plugin for the CMS interface (chapter 20): an ES module whose default export is definePlugin(…), embedded in the module's assembly with the files it imports:

<EmbeddedResource Include="ui\**" LogicalName="cms-ui/%(RecursiveDir)%(Filename)%(Extension)" />

Interface plugins without a module#

An interface plugin needs no module when it has no server code: the CMS settings list it (the admin UI's CMS tab, Interface plugins: a name, an address, and whether it loads). The address is a path on the site (/cms-plugins/site-tools.js, from its wwwroot) or an address on another site that allows it (CORS). A plugin runs with the signed-in editor's access, so add only one you trust.

Turning a module off#

CMS › Modules › Modules turned off lists the modules to turn off, by name, separated by commas. It applies at once.

Stays Goes at once Waits for a start where it is on
model types, transaction plugins, settings, rendering, data the interface plugin, commands (403), routes, job runs seed steps, user groups and logs, start-up work

Nothing stored is lost or left unguarded, so turning a module on again is only that. A module that builds on one turned off is off too.

The license#

RequiresLicense("sms") keeps a module inactive while the installation's license is known not to carry the feature: no API key, an invalid one, a license disabled or run out, or one without the feature. The license is asked through ICmsLicense, by default RelatudeCmsLicense, which asks Relatude Services with the server's API key, at most every ten minutes. While the license server cannot be reached, the module is given the benefit of the doubt: what was known stays known, and nothing known stays unknown.

Where a module stands#

CmsModules.StatusOf(name, store) answers where a module stands on a database, with the reason when it is not active:

State Why
Active
Disabled turned off in the CMS settings
Unlicensed the license is known not to carry a feature it needs
DependencyInactive a module it builds on is not active
NotInDatabase it does not apply to the database

The CMS interface lists the modules under Settings → Modules, each with its state, what it brings and how far its seed data has come, and every interface plugin under Settings → Plugins: the built-in ones, the modules', those of the CMS settings, and those added in the browser. The cms-modules command answers both:

{ "modules": [
    { "name": "events", "state": "active", "version": "1.0.0",
      "interfacePlugin": "/relatude.cms/api/modules/events/1.0.0/index.js", "dependsOn": ["venues"],
      "commands": ["events.upcoming", "events.cancel"], "jobs": ["events.close-past"],
      "settings": [{ "fileKey": "events", "title": "Events", "scope": "database" }], "seedSteps": 3, "seedVersion": 3 }
  ],
  "interfacePlugins": [
    { "name": "site-tools", "url": "/cms-plugins/site-tools.js", "enabled": true }
  ] }

Shipping a module#

A module is a class library referencing Relatude.DB.CMS, packed with dotnet pack like any other. EventsModule.csproj shows the whole of it: a Razor class library for the view, the framework reference to ASP.NET Core, and the embedded interface plugin. Before the first release:


Part VI — The CMS interface in TypeScript#

19. The TypeScript SDK#

@relatude/sdk is the client side of the CMS API (chapter 17): Relatude.DB's NodeStore and query builder in TypeScript. A query is written in the browser, typed by the model's interfaces, and runs on the server as the signed-in user, so it returns only what that user may read. A write runs through the store's transaction plugins, exactly as a write from C# does. The CMS interface is built on it, and so is every plugin that needs more than the content screens give (chapter 20).

The package is in Relatude.UI/packages/sdk. Its data side is exported as db:

import { db } from '@relatude/sdk';

Connecting#

A store needs the API's address and a token. TokenSession gets the token and keeps it fresh:

const session = new db.TokenSession('/relatude.cms/api');
await session.signIn('ann@velo.example', 'ann');          // checked by the site's VerifyLogin
const store = db.createStore({ baseUrl: '/relatude.cms/api', token: () => session.token() });

const me = await store.whoami();                         // { userId, anonymous, name, email, groups }
TokenSession Does
signIn(userName, password) auth/login: a token for a user the site's VerifyLogin accepts
fromSiteSession() auth/session: a token for the user the site's own sign-in cookie names, for a page on the same site
token() the token to send, renewed through auth/refresh once less than ten minutes are left; null when signed out or run out
signOut() forgets the token
onChange(handler) the token changed: signed in, renewed or signed out
current, signedIn the token as { token, expires, userId }, and whether it is still good

token is a function so that every request asks for the current token, and a renewed one is used without a new store. The session keeps the token in memory. A page that should survive a reload keeps it itself, as the CMS interface keeps it in sessionStorage, and hands it back:

const kept = sessionStorage.getItem('my-app.token');
const session = new db.TokenSession('/relatude.cms/api', { initial: kept ? JSON.parse(kept) : null });
session.onChange(info => info
  ? sessionStorage.setItem('my-app.token', JSON.stringify(info))
  : sessionStorage.removeItem('my-app.token'));

The base URL may name another origin, such as https://www.example.com/relatude.cms/api. The site then has to allow that origin with ASP.NET Core's CORS middleware, since the API sets no CORS headers of its own.

Describing the model#

A node type is described the way C# describes it: an interface named by its CodeName, with the property names exactly as in C#. The interface is for the compiler only. It decides which conditions a query offers, what its rows look like and which values a write takes; nothing of it is sent. These are Website2's page types, used through the rest of this chapter:

interface ISitePage {
  Name: string;
  MetaTitle: string;
  ShowInMenu: boolean;
  Parent: db.One<ISitePage>;            // PageTree.Parent
  Children: db.Many<ISitePage>;         // PageTree.Children
}
interface IArticle extends ISitePage {
  Author: string;
  PublishedUtc: Date;
  Intro: string;
  Body: string;                         // [HtmlProperty]
  Picture: db.FileValue;
}
interface IProductPage extends ISitePage {
  Sku: string;
  Price: db.Decimal;
  InStock: boolean;
  Summary: string;
  Picture: db.FileValue;
}
interface ISectionPage extends ISitePage {
  Intro: string;
}

const Page = db.nodeType<ISitePage>('ISitePage');
const Article = db.nodeType<IArticle>('IArticle');
const Product = db.nodeType<IProductPage>('IProductPage');
const Section = db.nodeType<ISectionPage>('ISectionPage');
C# TypeScript
string, bool string, boolean
int, double, float number
decimal, long db.Decimal, db.Long: numbers on the wire, so a decimal keeps a double's precision, which money fits in
DateTime, DateTimeOffset Date
TimeSpan db.TimeSpan, its invariant text, 1.02:03:04.5000000
Guid db.Guid, a string
string[], Guid[] string[], db.Guid[]
an enum a TypeScript enum with the same numbers
GeoCoordinate db.GeoCoordinate, { latitude, longitude }
FileValue db.FileValue
a relation's "one" and "many" ends db.One<T>, db.Many<T>
Reference<T>, References<T> db.Ref<T>, db.Refs<T>: ids, read and written

db.nodeType<T>(codeName) pairs a CodeName with its interface, so store.query(Product) knows its rows are products. A CodeName alone works too, typed as store.query<IProductPage>('IProductPage') or untyped as store.query('IProductPage'): then every property is reachable by name, with every operator, and checked against the schema when the query is written. An interface needs only the properties the code uses, and may leave the rest out.

CodeNames match exactly, as in the query language. n.price against a property Price fails before anything is sent, with Did you mean "Price"? CodeNames are case-sensitive.

The store fetches the schema the first time it needs it and keeps it: a query turns property names into ids and types every value by it, and a result reads dates back into Date objects by it. store.schema() hands it out (types, tryType, property, isA), and store.reloadSchema() fetches it again after the model changed on the server.

Reading nodes#

A row is a db.Node<T>: the node's properties, and system fields with a $ prefix that no C# name can have.

Field Is
$id the node's id
$type the CodeName of its concrete type, also when the query was over a base type
$displayName what lists call it
$createdUtc, $changedUtc Dates. $changedUtc does not move yet when a property is updated

A relation end is in a row only when the query included it, and Node<T> leaves the ends out until then, so product.Parent does not compile without include(). References read as ids, files as a FileValue with its url, and HTML with the store's internal links already turned into URLs.

Method Answers
get<T>(id) the node, or null when it does not exist or the user may not read it
getMany<T>(ids) the nodes the user may read, in the order asked for
exists(id) whether it exists and the user may read it
count(type?) how many nodes of the type and the types inheriting from it the user may read; every node without a type
getRelated(type, id, end) the related nodes, in the relation end's order, filtered by read access
getUrl(id) the node's URL, from the store's URL manager
fileUrl(id, property, adjustment?) a file's URL, or that of a resized or converted image
const article = await store.get<IArticle>(id);
const [parent] = await store.getRelated(Article, id, 'Parent');
const url = await store.getUrl(id);                        // "/news/five-trails-worth-the-climb"
const thumb = await store.fileUrl(id, 'Picture', { width: 320, height: 200, cropMode: 'Fill', requestedFormat: 'Webp' });

Unlike NodeStore.Get in C#, get applies read access: a node the user may not read comes back as null.

Queries#

store.query(type) starts a query over a type and every type inheriting from it, and store.queryAll() one over every node. The builder is the C# one: the same methods, written out as the same query text, and nothing is sent until the query runs. It is immutable, so a base query can be kept and extended:

const inStock = store.query(Product).where(n => n.InStock.isTrue());
const cheap = await inStock.where(n => n.Price.lt(1000)).orderBy(n => n.Price).execute();
const total = await inStock.count();

Conditions#

where() takes a function of n, which hands out a handle for each property, and the handle's methods make the condition. Which methods a handle has follows the property's type in the interface, so n.Price.startsWith('1') does not compile:

Property Methods
string eq, ne, oneOf, gt, gte, lt, lte, between, startsWith, contains, matchesSearch
numbers eq, ne, oneOf, gt, gte, lt, lte, between
Date the same, and inRange(from, to); a date may also be given as ISO text
boolean eq, ne, isTrue(), isFalse()
arrays contains(element)
GeoCoordinate isWithin(center, meters)
One<T> is(id): the end points at this node
Many<T> has(id): this node is among the related ones
Ref<T>, Refs<T> eq(id), ne(id), oneOf(ids); contains(id)
$createdUtc, $changedUtc as a Date, on every type

startsWith and contains compare ordinally, so case counts. matchesSearch finds words in the property's word index, which needs IndexedByWords on the property.

Conditions combine with db.and, db.or and db.not, or with the same methods on a condition. Chained where() calls are ANDed, and each is answered from the indexes on its own:

const { and, or, not } = db;
const found = await store.query(Product)
  .where(n => or(n.Price.lt(500), and(n.InStock.isTrue(), n.Name.startsWith('Trail'))))
  .where(n => not(n.Sku.startsWith('OLD-')))
  .where(n => n.$createdUtc.gt(new Date('2026-01-01T00:00:00Z')))
  .execute();

A value never goes into the query text. Each one is a parameter, typed by the property it is compared with, so a decimal is compared with a decimal and a date with a DateTime, which the literals of the query language cannot all say. eq(null) compares with null.

A condition the builder does not cover can be written in the query language itself, as db.raw(text) with n as the lambda variable. Its values are literals, with the literals' limits, so keep values in the builder's conditions, or run the whole query as text with typed parameters (rawQuery, below).

Method Keeps the rows that
whereIds(ids) have these ids
whereIn(n => n.Prop, values) have one of the values; cheaper than oneOf for a long list
whereTypes(types, includeDescendants = true) are of these types
whereRelates(n => n.End, id), whereNotRelates(…) are, or are not, related to the node through the end, read from the relation index
whereRelatesAny(n => n.End, ids) are related to any of the nodes
whereSearch(text, options?) match a text search over the node's combined text index
const news = await store.query(Section).where(n => n.Name.eq('News')).first();
const inNews = await store.query(Article).whereRelates(n => n.Parent, news!.$id).execute();
const pages = await store.query(Page).whereTypes([Article, Product]).count();

The search options are semanticRatio (0 searches keywords only, 1 meaning only, a value between blends the two), minimumVectorSimilarity, orSearch (any of the words, not all of them) and maxWordsEvaluated. Search needs the text index the model sets up; the Relatude.DB manual explains it.

Includes and traversal#

include() loads a relation end's related nodes with each row and widens the row's type with them: Node<X> | null for a "one" end, Node<X>[] for a "many" end.

const products = await store.query(Product)
  .include(n => n.Parent)                                  // p.Parent: Node<ISitePage> | null
  .execute();

const sections = await store.query(Section)
  .include(n => n.Children, {
    top: 5,                                                // at most five per section
    where: c => c.ShowInMenu.isTrue(),                     // only these are loaded; the sections are not filtered
    then: c => c.include(x => x.Children),                 // and the children's own children
  })
  .execute();

traverse() walks a relation breadth-first from the rows, and the query becomes one over the nodes it reaches between minLevel (1 unless set) and maxLevel steps away:

const below = await store.query(Section).whereIds(productsId)
  .traverse(n => n.Children, { maxLevel: 2 })              // children and grandchildren
  .where(n => n.ShowInMenu.isTrue())                       // now a query over ISitePage
  .execute();

direction ('default', 'reverse' or 'both') and maxVisited are there too.

Order and pages#

orderBy(n => n.Prop, 'asc' | 'desc') sorts, and orderByDescending(n => n.Prop) is short for the second; chain them for tie-breakers. page(index, size) returns one page, with totalCount still counting every row, and take and skip are there too. The server refuses a page or a take larger than CmsApiOptions.MaxPageSize, 10 000 rows by default.

Culture, edit view and hidden types#

A store reads in a context: a culture, and whether it sees what an editor sees. withContext() gives the same store reading in another context, sharing the connection and the schema, and the context goes with every command it sends:

const norwegian = store.withContext({ culture: 'nb-NO', cultureFallback: true });
const editing = store.withContext({ edit: true });         // unpublished nodes too
ReadContext Means
culture read and write in this culture, one the database has; the database's default when left out
cultureFallback fall back to another culture for nodes with no version in this one
edit edit view: unpublished nodes too, and edit-view access applied beside read access
hidden nodes of hidden types too

One query can do the same with culture(code), cultureFallback() and hidden().

Results#

What a query answers depends on what it ends in:

Ends in Answers
execute() items, totalCount, count, pageIndex, pageSize, isLastPage, durationMs
first() the first row, or null
count() how many rows match, answered from the indexes
sum(n => n.Prop) the sum of a number property over the rows
selectId().execute() the ids, without reading the nodes
select(n => ({ … })).execute() plain objects with the properties named, typed from the handles
search(text, options?).execute() ranked hits, each with its node, score and a text sample
facets() … .execute() the rows, and facets for a filter sidebar
pivot() … .execute(), groupBy(…) … .execute() groups on rows and columns, measures in every cell
shortestPath(n => n.End, from, to) { found, ids, items }
const rows = await store.query(Product).select(n => ({ name: n.Name, price: n.Price })).execute();
// rows.items: { name: string; price: number }[]

const ranked = await store.query(Page).search('trail', { semanticRatio: 0.5 }).page(0, 10).execute();
for (const hit of ranked.hits) console.log(hit.score, hit.node.Name, hit.sample?.fragments);

const sidebar = await store.query(Product).facets()
  .addValueFacet(n => n.InStock)
  .addRangeFacet(n => n.Price)
  .setFacetValue(n => n.InStock, true)                     // the visitor's choice
  .page(0, 20)
  .execute();
// sidebar.items: the rows; sidebar.facets: each with values { value, displayName, count, selected }

const byStock = await store.query(Product).groupBy(n => n.InStock).addCount().addAverage(n => n.Price, 'avg').execute();

A facet value is a token: pass its value, and value2 for a range, back unchanged to select it. A pivot takes rows and columns with addRow, addRowValues, addRowRanges and addRowRange and their column twins, a date by interval (addColumn(n => n.PublishedUtc, 'Month')), and measures with addCount, addCountDistinct, addSum, addAverage, addMin and addMax.

What the builder writes#

toRequest(schema) shows the query text and its parameters without running it:

const schema = await store.schema();
const request = store.query(Product).where(n => n.Price.lte(500)).orderBy(n => n.Name).toRequest(schema);
// { query: 'IProductPage.Where(n => (n.Price <= @P0)).OrderBy(n => n.Name)',
//   parameters: [{ name: '@P0', type: 'decimal', value: 500 }] }

rawQuery() runs a query written in the language itself, with parameters typed the same way, and answers what the server sends: a kind and the result, as chapter 17 lists them.

const r = await store.rawQuery('IProductPage.Where(n => n.Price < @P0).Count()', [{ name: '@P0', type: 'decimal', value: 1000 }]);
// { kind: 'value', value: 2 }

Writing#

Every write runs on the server as the user, through NodeStore.Execute, so the store's transaction plugins run just as they do for a write from C#: a plugin that gives new pages an address gives one to a page made in TypeScript too. A write answers the node as it was stored.

const p = await store.insert(Product, { Name: 'Trail Two', Sku: 'TR-2', Price: 18990, InStock: true, Parent: bikesId });
await store.update<IProductPage>(p.$id, { Price: 17990 });
await store.upsert(Product, knownId, { Name: 'Trail Three', Price: 21990 });
await store.delete(p.$id);                                 // or a list of ids; answers how many
Method Does
insert(type, values, { id?, ifNotExists? }) inserts a node; the properties not given get the type's defaults
update(id, values, { force?, ifExists? }) writes the properties given and leaves the rest; force writes without comparing with what is stored; null back when the node is gone and ifExists is set
upsert(type, id, values) updates the node when it exists, inserts it with this id otherwise
delete(ids, { ifExists? }) deletes the nodes and every relation they are in

Values#

Values are named by CodeName and parsed strictly by the property's type on the server: a value that does not fit is refused with a 400, rather than stored as the type's default.

Relations#

Method Does
relate(type, id, end, to) adds related nodes, at the end of the end's order; the other side follows
unrelate(type, id, end, to) removes them
setRelation(type, id, end, to) sets the end to exactly these: one id, or null, for a "one" end; a list for a "many" end
clearRelation(type, id, end) removes every related node
moveRelation(type, id, end, items, to) moves related nodes within the order: 'top', 'bottom', { offset }, { before: id } or { after: id }
setRelationOrder(type, id, end, order) replaces the whole order

The type is there for the compiler, which then knows the relation ends end may name.

await store.setRelation(Product, p.$id, 'Parent', bikesId);
await store.moveRelation(Section, bikesId, 'Children', p.$id, 'top');

Files#

const file = input.files![0];
const stored = await store.fileUpload(articleId, 'Picture', file, file.name);   // a FileValue, with its url
await store.fileDelete(articleId, 'Picture');

fileUpload takes a Blob (a File from an input is one), an ArrayBuffer or a Uint8Array, replaces what the property holds, and is refused above CmsApiOptions.MaxUploadBytes, 1 GB by default. fileUrl gives the URL of an image's variant: width, height, cropMode (Fill, Fit, Stretch or Auto), requestedFormat (Jpeg, Png, Gif, Webp or Avif) and quality. The variant is made in the background the first time it is asked for.

Transactions#

transaction() collects writes and sends them as one: all of them happen, or none. An insert gets its id in the browser (db.newId()), so a later action in the same transaction can use it:

const tx = store.transaction();
const helmets = tx.insert(Section, { Name: 'Helmets', Parent: productsId });
const helmet = tx.insert(Product, { Name: 'Road Helmet', Sku: 'AC-20', Price: 1290 });
tx.setRelation(Product, helmet, 'Parent', helmets);
tx.update<IProductPage>(oldHelmetId, { InStock: false });
const results = await tx.execute();                        // each action's answer, in order

A transaction takes insert, update, upsert, delete, resetProperty, addToProperty, relate, unrelate, setRelation, clearRelation, moveRelation and updateAddress.

The rest of the NodeStore#

Method Does
resetProperty(id, property) sets a property back to its default
addToProperty(id, property, n), multiplyProperty(id, property, n) changes a number in the store, without reading it first
updateAddress(id, address) the node's address, its URL segment; the store makes it unique, so read it back
updateDisplayName(id, name) its display name
changeType(id, type) makes it a node of another type, keeping the values the two types share
updateMeta(id, meta) system data: readAccess, editAccess, editViewAccess and publishAccess (group ids), collectionId, releaseUtc, expireUtc, deleted
timestamp() the database's transaction timestamp, which moves with every write

Changes from other editors#

onChanges(handler) hears every node a client of the API changes, as { id, type, kind }, with kind one of inserted, updated and deleted. The server sends each connection only the nodes its user may read, and deletions always. Changes made outside the API, by the site's own code or in the admin UI, are not sent.

const off = store.onChanges(changes => {
  if (changes.some(c => c.type === 'IProductPage')) void reload();
});
const offResync = store.channel.onResync(() => void reload());   // the stream was down: changes may be lost
// off() and offResync() when done

The first subscription opens the event stream: one for a store and every store made from it with withContext(). It reconnects by itself, waiting longer each time, and takes 25 seconds without even a ping for a dead connection. Events sent while it was down are lost, so anything that mirrors the server's state reads it again on onResync. channel.onConnectionState tells connecting, open and closed, and channel.disconnect() closes the stream. A module's own events (CmsApiEvents.Broadcast) are heard the same way, with store.channel.subscribe(name, handler).

Errors, stopping and signing out#

A command that fails throws a db.CommandError, with the server's message and a status: 400 for a payload, value or query the server cannot use, 401 without a valid token, 403 when CmsApiOptions.Authorize refuses, 404 for a node that does not exist, 500 for anything else.

Every method takes an AbortSignal last. Aborting it abandons the request and stops the command on the server as well, and the call throws a CommandError with cancelled set and the status 499:

let running: AbortController | null = null;
async function suggest(text: string) {
  running?.abort();                                        // the user typed again
  running = new AbortController();
  return store.query(Page).where(n => n.Name.startsWith(text)).take(10).execute(running.signal);
}

channel.onUnauthorized(handler) fires when the server refuses the token, on a command or on the stream: it ran out, or its user was deleted. Sign in again, then call channel.connect() to open the stream again.

Commands of your own#

A command a module registers on the server (chapter 17) is sent with store.send(type, payload). Payloads and answers are JSON in camelCase, so the shop-stock command of chapter 17, which reads StockRequest.Sku, is sent as:

const stock = await store.send<number[]>('shop-stock', { sku: 'TR-1' });

send adds the store's context to the payload as context, and the command commands lists every command the server knows.

20. Plugins for the CMS interface#

The CMS interface is the editors' application, which the site serves at /relatude.cms/ (chapter 17). A plugin adds to it: an item in the menu, a screen, a card on the dashboard, results in the search palette, or the editor for a kind of property. It is an ES module built on its own and loaded by the interface while it runs, never compiled into it, so one build of a plugin works with every release of the interface that has its SDK version.

The interface's own Dashboard, Content and Settings are plugins made with the same API, in Relatude.UI/packages/cms/src/builtins, and Relatude.UI/plugins/hello-world uses every extension point. Both are worth reading beside this chapter.

How a plugin is loaded#

The interface's page has an import map naming the six modules it shares: react, react/jsx-runtime, react-dom, react-dom/client, @relatude/sdk and @relatude/ui. A plugin is built with those left as bare imports.

  1. The interface signs the editor in and connects to the site.
  2. It activates its own plugins, then asks the installation for its plugins (the cms-modules command: the interface plugins of the site's modules, chapter 18, then those of the CMS settings), then adds those added in this browser, and loads each with import(url), in order.
  3. The browser resolves the plugin's bare imports through the import map, so the plugin runs on the interface's own React, SDK and component library: one instance of each.
  4. The module's default export, made with definePlugin, is checked against the SDK version, and its contributions are added.

A plugin that fails to load is marked as failed, with its error, and the interface goes on without it. Everything a plugin draws is inside a blade, and every blade renders inside an error boundary: a widget that throws takes the dashboard blade down, with a Try again button, not the interface. ?safemode=1 opens the interface without any plugin from the list.

A first plugin#

Plugins are built in the Relatude.UI workspace, which holds the SDK, the component library and the build preset. A plugin is a folder in Relatude.UI/plugins/:

Relatude.UI/plugins/stock/
  package.json
  tsconfig.json
  vite.config.mjs
  src/index.tsx

package.json:

{
  "name": "@relatude-plugins/stock",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build",
    "watch": "vite build --watch"
  },
  "dependencies": {
    "@relatude/sdk": "*",
    "@relatude/ui": "*"
  },
  "devDependencies": {
    "@relatude/plugin-kit": "*",
    "@types/react": "^19.1.0",
    "typescript": "^5.7.2",
    "vite": "^6.3.5"
  }
}

tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "include": ["src"]
}

vite.config.mjs:

import { defineConfig } from 'vite';
import { relatudePlugin } from '@relatude/plugin-kit';

export default defineConfig(relatudePlugin());

relatudePlugin() builds src/index.tsx into one ES module, dist/index.js, and leaves the six shared modules out of it; relatudePlugin({ entry, outDir }) changes either path. And src/index.tsx:

import { definePlugin, type BladeProps } from '@relatude/sdk';
import { Blade, EmptyState } from '@relatude/ui';

function StockBlade(props: BladeProps) {
  return (
    <Blade title="Stock" icon="package" onClose={props.depth > 0 ? props.close : undefined}>
      <EmptyState icon="package" title="Nothing here yet" />
    </Blade>
  );
}

export default definePlugin({
  name: 'stock',
  version: '1.0.0',
  sdk: '^1.0',
  contributes: {
    nav: [{ id: 'stock', title: 'Stock', icon: 'package', opens: 'stock.list', order: 25 }],
    blades: [{ id: 'stock.list', component: StockBlade, width: 560 }],
  },
});

From Relatude.UI, link the new folder into the workspace, build the plugin, and start the interface's dev server, which works against Website2 unless its login screen names another site:

npm install
npm run build --workspace @relatude-plugins/stock
npm run dev

The dev server, at http://localhost:5173, serves every plugin's dist folder at /ext/<folder>/, untransformed, the way an installation serves a plugin. Sign in, open Settings → Plugins → Add plugin from URL, enter /ext/stock/index.js and reload: Stock is in the menu.

npm run watch --workspace @relatude-plugins/stock rebuilds on every save; reload the interface to load the new build. The root npm run build:plugins builds only hello-world, so add a plugin to that script to build it with the rest, and add plugins/stock/src to tsconfig.check.json to have npm run typecheck check it.

definePlugin#

Field Is
name the plugin's name in Settings → Plugins; the only field required
version shown beside the name
sdk the SDK versions it works with: ^1.2 takes 1.2.0 and every later 1.x, 1.2.3 only that one. A plugin that does not fit the interface's SDK_VERSION is listed as incompatible and not activated
contributes nav, blades, widgets, search and propertyEditors, below
activate(cms) called once, after the contributions are added, with the CMS services; not awaited

The bundle's default export must be what definePlugin returns.

The CMS services#

useCms() gives the CMS services to any component the interface renders, a plugin's included. activate, a search provider and a search hit get the same object as cms.

CmsApi Is
store the RelatudeStore of chapter 19, signed in as the editor; null in the offline demo (?demo)
data the content as the content screens see it, below
dialogs confirm({ title, message?, confirmLabel?, cancelLabel?, danger? }), answering true or false; prompt({ title, message?, label?, initial?, placeholder?, confirmLabel? }), answering the text or null
toasts show(message, { tone?, actionLabel?, onAction? }), tone one of info, success and danger
blades open(bladeId, param?, { reset? }) on top of the stack, or as a fresh stack with reset; openPath([{ bladeId, param? }, …]) for a whole stack

The object stays the same while its store and data follow the signed-in editor, so read cms.store where it is used rather than keeping it from activate.

Extension points#

A nav item is an entry in the menu on the left. Clicking it opens its blade as a fresh stack, and the search palette lists it under Jump to.

NavContribution Is
id, title, icon the entry
opens, param the blade it opens, and the param that blade gets
order its place: Dashboard is 10, Content 20; 100 when left out
section 'main', the default, or 'system', the group at the bottom with Settings

Blades#

The interface's screens are blades: panels side by side, the newest on the right. A blade is a React component that takes BladeProps and renders a <Blade> from @relatude/ui.

BladeDefinition Is
id the name it is opened by. Begin it with the plugin's name, stock.list: a blade registered later with the same id replaces the earlier one, the interface's own included
component the component
width its width in pixels, 480 when left out, or 'fill' for the rest of the screen
BladeProps Is
param the one string the blade was opened with: an id, a type, a filter
depth its place in the stack, 0 for the first
close() closes it, and the blades to its right
openChild(bladeId, param?) opens a blade to its right, closing those already there
replaceParam(param) changes its param without remounting it, as the content editor does when a draft is saved
setCloseGuard(guard) an async check asked before it closes, false keeping it open; null removes it
entryKey the blade's key in the stack

The stack is the page's address, as in #/content.types!content.list:IArticle!content.edit:<id>, so a reload, a link or the browser's back button brings the same blades back. That is why a blade takes what it shows from param and loads the rest itself.

A close guard keeps unsaved work:

useEffect(() => {
  props.setCloseGuard(dirty
    ? () => cms.dialogs.confirm({ title: 'Discard changes?', confirmLabel: 'Discard', danger: true })
    : null);
}, [dirty]);

Dashboard widgets#

A widget is a card on the dashboard: { id, title, component, span? }, with span: 2 for a card two columns wide. The component takes no props and reads what it needs with useCms().

The search palette, opened with Ctrl+K (⌘K on a Mac), asks every search provider a moment after the editor stops typing, and shows up to 14 hits, grouped by their group.

SearchProvider Is
id the provider
search(text, cms) the hits for the text, or a promise of them
SearchHit Is
id, title, subtitle?, icon? the hit
group? the heading it is listed under; Results when left out
run(cms) what choosing it does

A provider that throws is logged and left out, and the palette shows the others' hits.

Property editors#

The content editor renders each property with an editor chosen by the property's editor type, and a plugin can add an editor or take one over. Of the editors for a type, the one with the highest priority wins. The interface's own editors have priority 0 and are registered first, so a plugin's editor needs a priority above 0 to take over.

The editor type follows the C# property, in this order:

Editor type Property
select a string with legal values, or an enum
html a string of the HTML string type ([HtmlProperty])
text a string of any other string type, or one whose name contains intro, summary, description, body, bio, text, notes or content
slug a string whose name contains slug, sku, address or urlname
string any other string, and a Guid
number int, long, double, float, decimal
boolean bool
date DateTime, DateTimeOffset
tags string[]
reference a relation's "one" end, or a Reference<T>

Names are matched ignoring case. Files, embedded nodes, "many" ends and the other kinds have no editor yet, and the editor leaves them out.

PropertyEditorProps Is
property id (the CodeName), name, type, required, placeholder, help; options for a select, referenceType for a reference
node the node being edited, with every value as edited so far, keyed by CodeName: node.values.Name
value the property's value
onChange(value) sets it; the editor saves it with the rest

An editor that counts characters under every text property, against a limit for the meta description:

import { type PropertyEditorProps } from '@relatude/sdk';
import { Textarea } from '@relatude/ui';

function CountedTextEditor({ property, value, onChange }: PropertyEditorProps) {
  const text = typeof value === 'string' ? value : '';
  const limit = property.id === 'MetaDescription' ? 160 : undefined;
  return (
    <div style={{ display: 'grid', gap: 4 }}>
      <Textarea rows={3} value={text} placeholder={property.placeholder} onChange={e => onChange(e.target.value)} />
      <span style={{ fontSize: 12, color: limit && text.length > limit ? 'var(--r-danger)' : 'var(--r-muted)' }}>
        {limit ? `${text.length} / ${limit}` : text.length} characters
      </span>
    </div>
  );
}

// in definePlugin's contributes:
// propertyEditors: [{ id: 'counter.text', propertyType: 'text', component: CountedTextEditor, priority: 10 }]

The content as the screens see it#

cms.data is the DataClient the content screens run on: the site's model made into collections and editable values. A plugin that lists or edits content the way the content screens do uses it, and gets the same lists, display names and saving.

DataClient Does
schema() the collections: the concrete node types of the site's model, Relatude.DB's own left out, each with id (the CodeName), name, plural, icon and its editable properties
typeSchema(typeId) one type, by CodeName
query(typeId, { search?, page?, pageSize? }) { items, total }, newest first; search tries the text index, then the text properties' contents
get(id) one node, its "one" ends read with it
create(typeId) an unsaved draft, its id beginning with draft-
save(node) inserts a draft, or writes the properties changed since the node was read; answers the node as stored
remove(id) deletes the node
search(text) up to eight nodes, of any collection
displayName(node) what lists call it
onChange(listener) the content changed, in this interface or by another editor; returns the unsubscribe

A NodeData is { id, type, createdUtc, updatedUtc, values }, its values as the editors show them: keyed by CodeName, dates as ISO text, an enum by name, a relation end as the related node's id. cms.data works in the offline demo too, where cms.store is null.

For anything else, the store: typed queries, relations, files, transactions, and the types that are not collections.

A plugin on the store#

The stock plugin grown into a real one: a blade listing the products, with a switch that sets InStock; a dashboard widget counting what is out of stock; and a search for SKUs. All three stay current through the event stream, whoever changes a product.

import { useEffect, useState } from 'react';
import { db, definePlugin, useCms, type BladeProps, type SearchProvider } from '@relatude/sdk';
import { Blade, Button, EmptyState, Spinner, Switch, Table } from '@relatude/ui';

// the part of Website2's model the plugin uses
interface ISitePage { Name: string; Parent: db.One<ISitePage> }
interface IProductPage extends ISitePage { Sku: string; Price: db.Decimal; InStock: boolean }
const Product = db.nodeType<IProductPage>('IProductPage');
type Row = db.Node<IProductPage>;

/** Every product, or with the param "out" those out of stock. */
function StockBlade(props: BladeProps) {
  const cms = useCms();
  const store = cms.store;
  const onlyOut = props.param === 'out';
  const [rows, setRows] = useState<Row[] | null>(null);

  useEffect(() => {
    if (!store) return;
    let live = true;
    const load = async () => {
      const all = store.query(Product).orderBy(n => n.Name);
      const r = await (onlyOut ? all.where(n => n.InStock.isFalse()) : all).execute();
      if (live) setRows(r.items);
    };
    void load();
    const offChanges = store.onChanges(changes => {
      if (changes.some(c => c.type === 'IProductPage')) void load();
    });
    const offResync = store.channel.onResync(() => void load());
    return () => { live = false; offChanges(); offResync(); };
  }, [store, onlyOut]);

  const toggle = async (p: Row) => {
    try {
      await store!.update<IProductPage>(p.$id, { InStock: !p.InStock });   // the stream reloads the list
    } catch (error) {
      cms.toasts.show(error instanceof Error ? error.message : String(error), { tone: 'danger' });
    }
  };

  const title = onlyOut ? 'Out of stock' : 'Stock';
  const close = props.depth > 0 ? props.close : undefined;
  if (!store) {
    return (
      <Blade title={title} icon="package" onClose={close}>
        <EmptyState icon="alert" title="No database" message="The offline demo has no store." />
      </Blade>
    );
  }
  return (
    <Blade title={title} subtitle={rows ? `${rows.length} products` : 'Loading…'} icon="package" pad={false} onClose={close}>
      {!rows ? <Spinner size="lg" /> : rows.length === 0 ? <EmptyState icon="package" title="Nothing here" /> : (
        <Table>
          <thead>
            <tr><th>Product</th><th>SKU</th><th>Price</th><th>In stock</th></tr>
          </thead>
          <tbody>
            {rows.map(p => (
              <tr key={p.$id} data-clickable="" onClick={() => props.openChild('content.edit', p.$id)}>
                <td>{p.Name}</td>
                <td>{p.Sku}</td>
                <td>{p.Price.toLocaleString()}</td>
                <td onClick={e => e.stopPropagation()}>
                  <Switch checked={p.InStock} onChange={() => void toggle(p)} />
                </td>
              </tr>
            ))}
          </tbody>
        </Table>
      )}
    </Blade>
  );
}

function OutOfStockWidget() {
  const cms = useCms();
  const [count, setCount] = useState<number | null>(null);

  useEffect(() => {
    const store = cms.store;
    if (!store) return;
    let live = true;
    const load = () => void store.query(Product).where(n => n.InStock.isFalse()).count().then(n => { if (live) setCount(n); });
    load();
    const off = store.onChanges(load);
    return () => { live = false; off(); };
  }, [cms]);

  if (count === null) return <Spinner />;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
      <strong style={{ fontSize: 28, color: count > 0 ? 'var(--r-warn)' : 'var(--r-ok)' }}>{count}</strong>
      <span style={{ flex: 1, color: 'var(--r-muted)' }}>products out of stock</span>
      <Button size="sm" disabled={count === 0} onClick={() => cms.blades.open('stock.list', 'out', { reset: true })}>
        Show
      </Button>
    </div>
  );
}

const skuSearch: SearchProvider = {
  id: 'stock.sku',
  async search(text, cms) {
    const prefix = text.trim().toUpperCase();
    if (!cms.store || prefix.length < 2) return [];
    const found = await cms.store.query(Product).where(n => n.Sku.startsWith(prefix)).take(5).execute();
    return found.items.map(p => ({
      id: `stock-${p.$id}`,
      title: p.Name,
      subtitle: p.Sku,
      icon: 'package',
      group: 'Products by SKU',
      run: c => c.blades.openPath([
        { bladeId: 'content.types' },
        { bladeId: 'content.list', param: p.$type },
        { bladeId: 'content.edit', param: p.$id },
      ]),
    }));
  },
};

export default definePlugin({
  name: 'stock',
  version: '1.0.0',
  sdk: '^1.0',
  contributes: {
    nav: [{ id: 'stock', title: 'Stock', icon: 'package', opens: 'stock.list', order: 25 }],
    blades: [{ id: 'stock.list', component: StockBlade, width: 560 }],
    widgets: [{ id: 'stock.out', title: 'Out of stock', component: OutOfStockWidget }],
    search: [skuSearch],
  },
});

Clicking a row opens the interface's own editor for the product to the right of the list, and typing ac in the search palette finds the accessories by their SKUs.

Opening the interface's own blades#

Blade param Is
dashboard.home the dashboard
content.types the collections
content.list a type's CodeName, IArticle a collection
content.edit a node's id, or new: and a type's CodeName, new:IArticle the editor; new: makes a draft
settings.home the settings, the plugin list among them

The component library#

@relatude/ui is the visual API for plugins. Its components follow the interface's theme, light and dark, and come from the interface through the import map, so they cost a plugin nothing to load.

Component For
Blade a blade's frame: title, subtitle, icon, onClose, toolbar, footer, busy, and pad={false} for a list from edge to edge
Button, IconButton variant (default, primary, ghost, danger), size (md, sm), icon; an icon button needs a label
Field a label, help and error around an input
TextInput, Textarea, Select, Switch, TagInput inputs; mono for text like code
Menu a drop-down of { id, label, icon?, danger?, disabled?, onSelect } and 'separator'
Dialog a modal; cms.dialogs covers confirming and asking for text
Tabs, Table, Card tabs, a table in the interface's style, a card
Badge, Spinner, EmptyState, Kbd, Avatar a status, loading, nothing to show, a key, a person
Icon, cx an icon by name, and joining class names

The icons are search, x, plus, check, chevron-right, chevron-down, chevron-left, sun, moon, monitor, user, users, logout, grid, layers, sliders, sparkle, file-text, package, tag, alert, trash, edit, calendar, clock, refresh, external, home, graph and more; another name draws alert.

What the components do not cover is styled with the design tokens, CSS variables that follow the theme: --r-surface, --r-surface-2, --r-ink, --r-muted, --r-faint, --r-line, --r-accent, --r-danger, --r-ok and --r-warn (with -soft variants of the last four), --r-radius, --r-radius-sm and --r-font-mono. A plugin is one JavaScript file, and a stylesheet the build writes beside it is not loaded, so keep styles inline or in the components.

Shipping a plugin#

dist/index.js is the whole plugin. The interface loads it from any URL the browser can import a module from:

Give the URL from the root (/…) or in full. The interface's page has a <base> pointing at the release's own files, so a relative URL would not find the plugin. Put the version in the path or the query string (index.js?v=1.0.1), so browsers fetch a new build rather than the one they kept.

An installation loads a plugin for every editor in one of two ways: listed in its CMS settings as an interface plugin (the admin UI's CMS tab, Interface plugins), or embedded in one of the site's modules, which serves it itself (chapter 18). Settings → Plugins → Add plugin from URL adds one in this browser only (localStorage), for developing it, and changes apply after a reload. The same page lists every plugin with where it comes from and its state: Active, Failed with the error, Incompatible with the SDK version it needs, Not licensed or Inactive when its module is held back, with the reason, or Disabled; a switch turns off a plugin added in the browser without removing it. Settings → Modules lists the site's modules themselves.

Rules for plugins#


Part VII — Reference#

21. API reference#

Member Namespace Role
AddRelatudeCMS(o => …) global Relatude.DB, CMS settings, analytics, the request context and content routing; returns RelatudeCMSBuilder
UseRelatudeCMS() global the analytics middleware
AddRendering(o => …) global the content routing core, which AddRelatudeCMS() adds: code defaults, selectors, kinds and the checks
AddCmsContentModel() global adds IItem, IPage, ITemplate and the template kinds to a datamodel
AddMvcRendering(), AddRazorPagesRendering() global the MVC and Razor Pages adapters
MapRelatudeCMS(pattern) global the content routes for pages and stored files, {**url} by default
AddApi(o => …), MapRelatudeCmsApi() global the CMS API: its services, and its routes
MapRelatudeCmsUI() global the CMS interface, from services.relatude.com, with the copy it keeps
CmsApiOptions Relatude.DB.CMS.Api UIPath, Path, ServicesUrl, TokenLifetime, VerifyLogin, SessionTokens, AllowAnonymous, Authorize, MaxPageSize, MaxQueryLength, MaxUploadBytes
CmsApiCommands, CmsApiContext Relatude.DB.CMS.Api the command endpoint, Register(type, handler, readOnly), and a command's caller, store and payload
CmsApiEvents Relatude.DB.CMS.Api the event stream: Broadcast, Send
ICmsApiTokens, DataProtectionCmsApiTokens, CmsApiAuthenticator Relatude.DB.CMS.Api issuing and reading tokens, and the caller of a request
CmsUiServer, ICmsUiSource, ServicesCmsUiSource Relatude.DB.CMS.Api serving the interface, and where its releases come from
ContentRenderingOptions Relatude.DB.CMS.Web.Rendering Render<T>, RenderRazorView<T>, RenderMvcController<T>, RenderRazorPage<T>, RenderResult<T>, ViewPerType(), Select((http, m) => …), TemplateKind<T>(t => …), TargetOf(template)
TemplateRules Relatude.DB.CMS.Web.Rendering Fits, Distances, All and ViewName: which templates a node type may use, and the view named after a type
TemplateCheck Relatude.DB.CMS.Web.Rendering the startup check: Run(db, options, services) and Problem(target, services)
RazorViewTarget, MvcControllerTarget, RazorPageTarget, ResultTarget Relatude.DB.CMS.Web.Rendering what renders content
ContentResolver Relatude.DB.CMS.Web.Rendering GetContent(http), GetRender(http), GetFile(http), IsDenied(http), overridable Resolve(request), and Context(http) and Match(node) for overrides
RelatudeCmsContext Relatude.DB.CMS.Web the request: Db, Admin, User, CultureCode, Url, Node, AccessDenied, CanRead, Content, Render, GetUrl
CmsRequestOptions Relatude.DB.CMS.Web User, Culture and LoginRedirect, set on AddRelatudeCMS(o => …)
CmsPrincipal.For(user, scheme) Relatude.DB.CMS.Web the principal to sign a CMS user in with
ContentMatch, ContentRender Relatude.DB.CMS.Web.Rendering the content of a request, and what renders it
[FromContent], GetContent<T>(), GetContentMatch() Relatude.DB.CMS.Web.Rendering the content in actions, pages and views
RelatudeContentController Relatude.DB.CMS.Web.Rendering renders RazorViewTargets
MvcContentRouteTransformer.TrySetRouteValues Relatude.DB.CMS.Web.Rendering route values for composed routers
IRelatudeDBAccessor Relatude.CMS.Engine the default store and IsOpen
RelatudeUrlHelper.ToUrlSlug Relatude.CMS.Engine.Web a readable URL slug from a name
IItem, IPage, ITemplate, IRazorViewTemplate, IMvcControllerTemplate, IRazorPageTemplate Relatude.CMS.Models pages and templates
ISite, ITree, ICMSUser, ICmsUserGroup, IEmailContent Relatude.CMS.Models sites, users and email content
ICountry Relatude.CMS a country
CmsSettings, CmsAnalyticsSettings, CmsSettingsDefinition Relatude.CMS.Settings the CMS settings
CmsServerSettings, CmsServerSettingsDefinition Relatude.CMS.Settings the CMS's server settings: the interface's path
IAnalyticsHost, PageViewLog, PageView, PageViewQueue, DailyVisitorHash Relatude.DB.CMS.Web.Analytics analytics
HtmlReader, HtmlRewriter, IHtmlRewriteHandler, HtmlEntities Relatude.CMS.Utils HTML utilities
IDonation Relatude.CMS.Models.Donations the donation node
DonationAdminProjection, DonationRow, DonationListResult, DonationDetail Relatude.CMS.Models.Donations.Admin the donations admin contract
db.createStore, db.RelatudeStore, db.StoreTransaction @relatude/sdk the NodeStore over the CMS API, and its transactions
db.TokenSession, db.RelatudeChannel, db.CommandError @relatude/sdk tokens, the command channel and event stream, and failed commands
db.nodeType, db.Node, db.One, db.Many, db.Ref, db.Refs, db.Decimal, db.FileValue @relatude/sdk describing a model in TypeScript, and the rows it reads
db.NodeQuery, db.and, db.or, db.not, db.raw @relatude/sdk the query builder
definePlugin, useCms, CmsApi, DataClient, SDK_VERSION @relatude/sdk a plugin, and the CMS services it is given
Blade, Button, Field, Table, Icon, … @relatude/ui the components plugins draw with
relatudePlugin() @relatude/plugin-kit the Vite build of a plugin

22. Moving from the old routing#

Earlier versions routed pages through a set of resolver and context services. They are removed.

Removed Replacement
RelatudeDynamicRouting MapRelatudeCMS(), or a composed transformer (chapter 7)
IRelatudeAddressResolver, RequestInfo ContentResolver and ContentMatch
IRelatudeRequestContext.GetContent<T>() [FromContent] or HttpContext.GetContent<T>()
IRelatudeUrlService.GetUrl(page), RelatudeUrlHelper.GetUrl db.GetUrl(page)
RelatudeUrlHelper.GetUniqueUrlSlug(name, db) RelatudeUrlHelper.ToUrlSlug(name); the store makes it unique
ITemplate.Controller, Action, PagePath the kinds IMvcControllerTemplate, IRazorViewTemplate and IRazorPageTemplate, or no template and a code default for the type
the TemplatePages relation IPage.Template, a Reference<ITemplate>
IMvcControllerResolver a selector: o.Select((http, m) => …)
UrlHandlerMiddleware MapRelatudeCMS(), which serves file URLs
MapRelatudeContent() MapRelatudeCMS(), renamed to pair with AddRelatudeCMS() and UseRelatudeCMS()
Database, the transient NodeStore RelatudeCmsContext.Db in a request, which reads as the visitor; IRelatudeDBAccessor elsewhere

A template that pointed at controller Article, action Index, becomes an IMvcControllerTemplate with the controller Article. When editors never chose between templates for articles, .RenderMvcController<IArticle>("Article") replaces the template altogether.

23. Pitfalls#

24. Every feature in one Program.cs#

The whole manual in one host: every option it describes, set the way a real site would set it. Each group names the chapter that explains it. No site needs all of it, and the smallest setup in chapter 2 stays the place to start.

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http.HttpResults;      // RazorComponentResult
using MySite;                                     // the site's own classes, listed below
using MySite.Models;
using Example.Events;                             // the example modules (chapter 18)
using Relatude.CMS.Models;
using Relatude.CMS.Models.Donations;
using Relatude.CMS.Settings;
using Relatude.DB.CMS.Web;
using Relatude.DB.CMS.Web.Rendering;
using Relatude.DB.Native.Models;                  // ISystemUser
using Relatude.DB.NodeServer;
using Relatude.DB.Web;

var builder = WebApplication.CreateBuilder(args);

// ---- the database (chapter 2) ------------------------------------------------------------------
builder.AddRelatudeDB(o => {
    o.DefaultDataFolderPath = "data";                       // relatude.data/ and relatude.settings/ go in ./data
    o.OnDatamodelInit = (dm, _) => {
        dm.AddCmsContentModel();                            // IItem, IPage, ITemplate and the template kinds
        dm.AddNamespace<ISitePage>();                       // the site's types, its PageTree and IJsonTemplate
        dm.AddNamespace<ISystemUser>();                     // the engine's user model, which the CMS users build on
        dm.Add<ICMSUser>();                                 // the site's members (chapter 2)
        dm.Add<ICmsUserGroup>();
        dm.AddNamespace<IDonation>();                       // donations (chapter 16)
    };
    o.OnStoreInit = db => db.RegisterTransactionPlugin(new PageAddressPlugin());   // addresses from names (chapter 6)
    o.OnStoreOpenBackground = SiteSeeder.SeedIfEmpty;       // content on the first start, off the request path

    // ---- URLs (chapter 6) ------------------------------------------------------------------------
    o.CreateUrlManager = _ => new DefaultUrlManager(new DefaultUrlManagerOptions {
        Parents = [new UrlParentRelation { ParentRelationName = nameof(PageTree) }],   // /products/bikes/trail-one
        Domains = [
            new UrlDomain { Host = "www.bikes.no", RootId = SiteSeeder.BikesHomeId },  // each domain its own site,
            new UrlDomain { Host = "www.skis.no", RootId = SiteSeeder.SkisHomeId },    // with its home page at "/"
        ],
        FallbackRootId = SiteSeeder.BikesHomeId,            // localhost and every other host
        UrlFormat = NodeUrlFormat.OnlyAddress,              // the default: /products, not /{id}/products
        IncludeTrailingSlash = false,                       // the default: /products, not /products/
        BaseAddressAssets = "https://cdn.bikes.no",         // file URLs on a CDN in front of this site
        AssetUrlSignatureKey = SiteSeeder.AssetKey,         // an edited file URL stops working
    });
});

// a plugin registered the way a library does it, whatever order the setup calls come in (chapter 2)
builder.Services.AddSingleton<IStoreInitializer, AuditInitializer>();

// ---- rendering (chapters 2 and 8 to 11) ----------------------------------------------------------
builder.Services.AddRazorComponents();                      // for the Blazor campaign pages below
builder.AddRelatudeCMS(o => o.LoginRedirect = true)        // the database, settings, analytics, the request context, content routing;
                                                            // the default: a page the visitor may not read redirects to login (chapter 2)
    .AddMvcRendering().AddRazorPagesRendering()             // MVC and Razor Pages
    .AddModule<VenuesModule>()                              // modules: everything a feature brings (chapter 18)
    .AddModule(new EventsModule { ParentPageId = SiteSeeder.BikesHomeId })
    .AddRendering(o => o
        // selectors: decide first, for every page (chapter 8)
        .Select((http, m) => http.Request.Query["format"] == "json"
            ? new ResultTarget((_, c) => Results.Json(new { c.Content.Id, c.Content.Name, culture = c.CultureCode }))
            : null)
        .Select((http, m) => http.Request.Query["view"] == "print" && m.Content is IArticle ? new RazorViewTarget("ArticlePrint") : null)

        // a selector testing a template's name overrides that template, whatever its kind (chapter 8)
        .Select((http, m) => m.Template?.Name == "Campaign"
            ? new ResultTarget((_, c) => new RazorComponentResult<CampaignPage>(new { Content = c.Content }))
            : null)
        .Select((http, m) => m.Template?.Name == "Article" && m.Content is IArticle { Body.Length: > 20_000 } ? new RazorViewTarget("ArticleLong") : null)

        // a template kind of the site's own, which editors create in the admin UI (chapter 8)
        .TemplateKind<IJsonTemplate>(t => new ResultTarget((http, m) => Results.Json(new { m.Content.Name, fields = t.Fields })))

        // code defaults, for content without a template: the closest type wins (chapter 8)
        .RenderRazorView<IHomePage>("Home")                      // Views/Shared/Home.cshtml
        .RenderRazorView<IArticle>()                             // no name: the type's, so Views/Shared/Article.cshtml
        .RenderMvcController<ISectionPage>("Section")          // SectionController.Index
        .RenderRazorView<IProductPage>(p => p.InStock ? "Product" : "ProductSoldOut")   // decided per page
        .RenderRazorPage<ILandingPage>("Landing")           // Pages/Landing.cshtml
        .RenderResult<IFeedPage>((http, feed) => Results.Text(FeedWriter.Rss(feed), "application/rss+xml"))
        .RenderRazorView<IPage>("Default"));                     // any other page. Or, in its place, .ViewPerType():
                                                            // the view named after each type, "FaqPage" for IFaqPage

// ---- the site's own services (chapters 2 and 7) ------------------------------------------------
builder.Services.AddSingleton<ContentResolver, PreviewContentResolver>();   // ?preview=<id> for editors
builder.Services.AddScoped<LegacyRouting>();                                // the old site's URLs
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => o.LoginPath = "/login");
builder.Services.AddAuthorization();

var app = builder.Build();

// ---- the pipeline (chapters 2 and 7) ------------------------------------------------------------
app.UseRelatudeCMS();                                       // the database, the admin UI at /relatude.db, analytics
app.UseStaticFiles();                                       // wwwroot, before routing
app.UseRouting();
app.UseAuthentication();                                    // editors sign in to preview
app.UseAuthorization();
app.UseAntiforgery();                                       // forms in the Razor components

app.MapGet("/api/status", () => "ok");                      // explicit routes win over content
app.MapGet("/api/analytics", (RelatudeCmsContext ctx) =>   // CMS settings, read through the database (chapter 13)
    ctx.Db.Settings.Get<CmsSettings>().Analytics.Enabled ? "on" : "off");
app.MapPost("/login", async (RelatudeCmsContext ctx, string email, string password) => {   // members sign in (chapter 2)
    var user = Members.Verify(ctx.Admin, email, password);  // the site's own password check
    if (user == null) return Results.Unauthorized();
    await ctx.Http.SignInAsync(CmsPrincipal.For(user, CookieAuthenticationDefaults.AuthenticationScheme));
    return Results.Redirect("/");
});
app.MapRelatudeCMS();                                       // pages and stored files, range requests included
app.MapDynamicControllerRoute<LegacyRouting>("{**url}");    // what the database does not address
app.MapFallback(() => Results.NotFound());                  // what nothing answers

app.Run();

The site's own classes it names, and where each is explained:

Class What it is Chapter
ISitePage, PageTree, IHomePage, ISectionPage, IArticle, IProductPage, ILandingPage, IFeedPage the page types, and the relation that makes the tree 3, 6
IJsonTemplate a template kind of the site's own, ITemplate with a Fields property 8
SiteSeeder the fixed ids of the two home pages, the signing key, and the content made on the first start 6
PageAddressPlugin gives every new page an address from its name 6
AuditInitializer an IStoreInitializer that registers a plugin of its own 2
VenuesModule, EventsModule the example modules in EventsModule/, beside Website2 18
Members checks a password against what the site stores for a user, and returns the ICMSUser 2
PreviewContentResolver a ContentResolver that shows unpublished pages to editors 7
LegacyRouting a route transformer for the URLs the database does not address 7
CampaignPage, FeedWriter a Razor component, and a class that writes RSS 11
SectionController, the views in Views/Shared/, Pages/Landing.cshtml what the code defaults and templates name 9, 10

What is not in Program.cs, because it is not set in code:

Where to look when this manual runs out#