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:
- keeps its database in
relatude.data/and its settings inrelatude.settings/relatude.db.json, both in the project folder, and creates them on the first start; - serves the admin UI at
/relatude.db; - gives a page with the address
hello-worldthe URL/hello-world, and serves nothing at/until it has a home page (chapter 6); - sends stored files, such as images and video, at the URLs
db.GetUrl(file)makes; - leaves every URL the database does not know to the rest of the application, which ends in a 404;
- records no page views until analytics is turned on in the admin UI.
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" }
]
- Every source needs its own unique
Id; the store refuses to start without one. - A base interface whose namespace is not registered silently drops its members from every type derived from it.
- In code,
dm.AddCmsContentModel()addsIItem,IPage,ITemplateand the template kinds, anddm.AddNamespace<IArticle>()adds every type in the site's own namespace.dm.AddNamespace<IItem>()would add the CMS's users, groups and sites too, which build on Relatude.DB's own system types and fail without them.
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:
- A view name in a template ties content to code. Rename
ArticleWide.cshtmland every template naming it breaks. The startup check logs every name that no longer exists (chapter 8). NodeTypesholds type ids. The CMS's own types have fixed ids. Give the site's types one too, with[Node(Id = "…")]on the interface, since a type without one gets its id from its name, and renamingIArticlewould leave every template listing it pointing at nothing.
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:
- every page answers at
/{address}, and a page without an address has no URL; - an address is unique across the site: a second
hello-worldis saved ashello-world-2; /addresses nothing until the site has a home page, a root (below).
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
- An address only has to be unique among siblings. Both
bikespages above are fine. - Nothing about the path is stored. Moving a page, or renaming a section, changes every URL below it on the next read.
- The root's own address is not part of the URLs.
/products, not/home/products. - Several relations can lead to a parent, such as pages under pages and documents under pages.
List them in
Parents, in priority order.
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.
Links in rich text#
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#
- Explicit routes win. The content routes are catch-alls on
{**url}, the lowest precedence, soapp.MapGet("/search", …)answers/searcheven when a page has the addresssearch. - Static files come first when
UseStaticFiles()runs beforeUseRouting(): a request for/css/site.cssnever asks the database. - Everything else falls through to the host's other routes,
MapFallback, or a 404. - While the database opens, Relatude.DB answers every request with a progress page.
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>
- Variants, such as a resized image or a converted video, are made on first request. A browser waits for them, so they go ahead of conversions running in the background.
- Range requests get
206 Partial Content, which video and audio players need to seek. A file kept on local disk is read straight from it; one on blob storage is sent whole. - Caching. A finished variant may be cached for 30 days, and its URL changes when the file does. A variant still being converted is not cached.
- Read as the visitor. A file on a page the visitor may not read gets the same login redirect as the page.
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:
- a template renders by its kind: a view template renders its view, a controller template its action, a Razor Page template its page (chapter 5);
- a page without a template gets the template made for its type: the one whose
NodeTypesis closest to the page's type, the same way code defaults are chosen.
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:
- A template as close as a code default wins over it. Editors can take over a type by making a template for it, with no change to the code.
- A template for no types comes last, after every code default. It is the layout for whatever nothing else covers.
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:
- Content pages render statically, with interactive islands inside them. The page is found from
the request, which exists only while the component renders on the server. Interactive parts go in
child components with their own
@rendermode, likeLikeButtonabove. - Never pass a node into an interactive component. Its parameters are serialized to the browser
or the circuit, and a node's relations would pull in every related node. Pass an id or a small
projection; the component reads what it needs through
RelatudeCmsContext.Db, or its own service.
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:
- the defaults in code;
relatude.settings/cms.json, shared and committed with the application;- what the admin UI saved on this server;
RelatudeDB:CustomSettings:cmsin 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:
- the admin UI (
/relatude.dbby default) and the info path the bot check is served from (/-info/); - paths starting with one of
ExcludedPaths; - pages fetched ahead of a visit that may not happen (
Sec-Purpose,PurposeorX-Mozsaying prefetch).
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.
No cookie and no address#
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('?') ? "&" : "?", "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.
- The datamodel (
IDatamodelInitializer). After the host'sOnDatamodelInit, every module that applies adds its model types, dependencies first. A module applies to a database whose datamodel has the CMS content model (IItem), unlessAppliesTosays otherwise, and never to one without a module it builds on. - 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. - 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);
})
- The database remembers the last step it ran, in its storage:
cms/modules/{name}.json. A module new to a database runs every step; a module that gets a new step in an update runs that one step at the next start. That makes a step the place for a change to data as well as for the first data. - A step that throws stops the steps after it, and the error is logged. They are tried again, with it, at the next start.
- Never renumber a released step. A database that ran step 2 never runs a step 2 again.
- Give seeded nodes fixed ids, so a step finds what it made, and later steps and other modules can find it too.
- Steps run as the master admin, through the store's synchronous writes, so the transaction plugins see what they write.
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:
- the whole node (
OnBeforeNodeAction): an insert, an update of a loaded node, an upsert; - some of its properties (
OnBeforePropertyAction): how the CMS API'supdatecommand, and so the CMS interface, saves, sending only what changed.
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)" />
- The CMS API serves the files at
{api}/modules/{name}/{version}/…, cached by browsers as immutable, since the version is in the path. The version is the assembly's informational version unless the module overridesVersion. - As it starts, the CMS interface sends
cms-modulesand imports the interface plugin of every active module. Its bare imports resolve through the interface's import map, so it shares the interface's React, SDK and components. - The plugin can be plain JavaScript, with
createElementin place of JSX and no build step, as the example's is, or built with@relatude/plugin-kitinto the folder that is embedded. - A module added without its embedded entry stops the start, saying how to embed it.
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:
- Pin the ids of its node types and relations (
[Node(Id = …)],[Relation(Id = …)]), so a rename or a move keeps the data. - Keep its model types in a namespace of their own, so
AddNamespaceadds nothing else. - Fix its name. The database remembers its seed data by it, the settings turn it off by it, and its commands and jobs are named after it.
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).
Relations, types, ids and search#
| 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.
nullresets a property to its default.- A
Dateis sent as ISO text, and ISO text is taken as well. - An enum takes its number or its name.
- A relation end takes an id, or a list of ids for a "many" end, and is set to exactly those.
- A reference takes an id, and references a list of ids.
- A file is never a value: it is uploaded, below.
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.
- The interface signs the editor in and connects to the site.
- It activates its own plugins, then asks the installation for its plugins (the
cms-modulescommand: 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 withimport(url), in order. - 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.
- 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#
Navigation#
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().
Search#
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:
- From the site. Put it under the site's
wwwroot, such aswwwroot/cms-plugins/stock/index.js, served byapp.UseStaticFiles(), and add/cms-plugins/stock/index.js. Same origin, nothing else to set up. - From elsewhere. A full
https://URL works when its server answers withAccess-Control-Allow-Origin, since the browser fetches modules with CORS.
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#
- Never bundle the shared modules.
relatudePlugin()leaves them out. A second React breaks every hook, and a second SDK has its ownuseCms()context, which no shell provides. - Begin every id with the plugin's name. Blade ids share one namespace, and a later blade replaces an earlier one with the same id.
- Read
cms.storewhere it is used, and expect null in the offline demo. - Values are keyed by CodeName.
node.values.Nameandn.Name, matched case-sensitively. - A blade's state is its
param. The address keeps the blade's id and param, nothing else. activateis not awaited. Work it starts runs beside the interface's own start, and its errors are the plugin's to catch.- A button is not access control. The API lets every signed-in user write, unless
CmsApiOptions.Authorizesays otherwise (chapter 17): a rule belongs on the server, where a plugin cannot leave it out.
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#
- Namespaces do not follow folders. The rendering types are in
Relatude.DB.CMS.Web.Rendering,ICountryis inRelatude.CMS, and the setup extensions are in the global namespace. - Datamodel sources match namespaces exactly.
Relatude.CMS.Modelsdoes not coverRelatude.CMS.Models.DonationsorRelatude.CMS. Createdoes not store. Follow it withInsert, and callUpdateafter changing a loaded node.- Layout variants of one type need a template on every page. Two templates listing the same type are ambiguous for a page that names none.
- A template beats a code default only when it is as close. A template listing
IPagedoes not take over articles that haveRenderRazorView<IArticle>(); one listingIArticledoes. AddNamespace<IItem>()pulls in the user and site types. UseAddCmsContentModel(), and register a site namespace withAddNamespaceonly when it holds nothing but node types.- Names in templates and code defaults are checked at start, not when saved. Watch the log after renaming a view, controller or Razor Page.
- A page with nothing to render it is not rendered. It falls through to
MapFallbackor a 404, like a URL with no content. - Explicit routes beat content. A page at
/searchis hidden byapp.MapGet("/search", …). - Controllers for
MvcControllerTargetare conventionally routed. No[Route], no[ApiController]. - Serialize projections, never nodes. Relation ends are enumerable, so a serializer walks the graph.
For the same reason
[FromContent]skips validation. - A read by id ignores read access.
db.Get(id)andTryGetreturn the node whatever its read access; queries and relation properties filter by it. AskRelatudeCmsContext.CanRead(key), or query, before showing a node the visitor reached by id. Adminis for trusted code. Rendering content for the visitor fromRelatudeCmsContext.Adminshows what the visitor may not read.- File URLs need
MapRelatudeCMS(). A host routing a legacy system maps its router beside it, never in its place (chapter 7). - Compression and proxies come first.
UseResponseCompression()andUseForwardedHeaders()go beforeUseRelatudeCMS(). - A new address can come back changed. The store appends
-2,-3when the URL would collide, and drops characters outside ASCII, so read the address back instead of assuming it. - Values in
db.raw()are literals. A decimal property compared with100there is compared with 0. The builder's conditions, andrawQuerywith typed parameters, say decimal andDateTime(chapter 19). - A module's commands and jobs are named after it.
events.upcoming, notupcoming: anything else throws when the module is added (chapter 18). - A transaction plugin sees whole nodes and single properties. The CMS interface saves only what
changed, so a rule checked in
OnBeforeNodeActionalone is not checked for its saves. - A module's seed steps are never renumbered. A database remembers the last step it ran by number.
- A plugin's URL starts at the root. The interface's
<base>points at its release, so/ext/stock/index.jsloads andext/stock/index.jsdoes not.
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:
- Analytics is turned on in the admin UI, on the database's CMS tab (chapter 14).
- Templates are created by editors in the admin UI, and a page's template is a field on the page (chapter 5).
- File URLs come from
db.GetUrl(file)in the views (chapter 7). - The startup check logs what is wrong with templates and code defaults when the store opens (chapter 8).
- Read access is set per page, type or store in the admin UI, by group, and the store enforces it for every read as the visitor (chapter 2).
Where to look when this manual runs out#
- The Relatude.DB manual: modelling, queries, transactions, files, the URL manager, custom logs and settings.
- The READMEs of
Relatude.DB.EcomandRelatude.DB.Marketingfor commerce and newsletters. - The tests in
Relatude.CMS.Tests, which run content rendering on a real web application and the resolver on a real store in memory. EventsModule/beside Website2, a module with every part a module can have.- The source in
Relatude.CMS/, which wins over anything written here.