Skip to content
Menu
Search
Browse documentation & this page

Developer Guide

Extending the CMS

Field options, validators, audience criteria and scheduled jobs — all found by deriving from a type.

Code that plugs into the CMS rather than rendering your site lives under Extensions/. Nothing in there is reached by a template, and apart from Settings pages nothing in there has a URL.

Folder What lives there Contract
Extensions/Fields/ Field option providers, validators, audience criteria IFieldOptionsProvider, IFieldValidator, IAudienceCriterion
Extensions/Jobs/ Work the platform runs on a timer ScheduledJobBase
Extensions/Settings/ Pages of your own on the CMS Settings menu (see Adding Settings pages) [SettingsPage] on a Razor Page
Extensions/Sections/ Sections of your own on the CMS top bar (see Adding a section) [SectionPage] on a Razor Page

Two extensions are not in that folder because they are not server code. An action on a tree's right-click menu and a control of your own for a field are scripts in wwwroot. See Adding tree actions and Adding a field editor.

Names Cendia keeps

Your scripts run in the editor on the same page as Cendia's, and the Form block and the preview bar bring Cendia's stylesheets onto your site's pages. Two names that match there replace or restyle each other, quietly. So Cendia keeps to a few prefixes, and the rest are yours:

Where Cendia uses Keep your names out of
JavaScript globals, Alpine components and stores cmsExtensions, CmsPageTree, CMS_…, __cms… anything starting cms, Cms, CMS_ or __cms
CSS classes on your site's pages mc-form-… (the Form block), cmspv… (the preview bar) mc-form- and cmspv

Every rule in those two stylesheets is scoped by one of their classes, so they style nothing of yours. To restyle a form, target its mc-form- classes from your own stylesheet. Inside your scripts, wrap your code in a function or a module so that only the names you mean to share are global, and give those a prefix of your own.

Cendia's build fails if one of its own names breaks these rules, so the list above cannot grow without this page changing too.

Deriving from the type is the registration

Every contract here is found by a scan of the assemblies your app was built against. A class is picked up on the next start with no call in Program.cs and no list to keep in sync.

The same scan finds contributions inside a NuGet package — which is the point of it. The compiler drops a reference to an assembly your code never mentions, and that is exactly the plug-in you are trying to load.

Seeing what is installed

Settings > Extensions lists what your site's own code and each package add to the CMS: page, block and media types, Settings pages, sections, scheduled jobs, collections, audience criteria, sitemap contributors, field option providers and validators. It also lists the scripts each one loads into the editor, with the tree actions and field editors each script registers, and what answers the parts chosen in configuration (media storage, mail, form captcha, secret store and site search).

The page is read from the running code, so it needs no upkeep and there is nothing to switch on or off. An extension you expected and do not see there was not found; check that its package is referenced and, for a package, that its AddXxx() call is in Program.cs. What Cendia itself ships is not listed.

Administrators can open it.

Options computed at runtime

[FieldOptions(typeof(RegionOptions))]
public DropDown? Region { get; set; }

The provider reads whatever it likes — configuration, a database, a service — so the list behind a dropdown changes when its source changes, with no rebuild and no schema reconcile.

Options that come from the CMS itself need no class of yours. The languages an installation is configured for already ship as a provider:

[FieldOptions(typeof(Cendia.Schema.SiteCultureOptions))]

Scheduled jobs

Derive from ScheduledJobBase and the job appears on Settings → Scheduled Jobs on the next start, schedulable exactly like the ones the platform ships.

[ScheduledJob(
    DisplayName = "Report Stale Drafts",
    Description = "Lists drafts that nobody has touched for a fortnight.")]
public sealed class StaleDraftReportJob : ScheduledJobBase
{
    private readonly CmsDbContext _db;

    public StaleDraftReportJob(CmsDbContext db)
    {
        _db = db;

        // Only claim this if the work really checks, which this one does.
        IsStoppable = true;
    }
}

Note the plain constructor rather than a primary one. IsStoppable has to be set in a constructor body: discovery builds one instance at startup purely to read that property and decide whether the job's screen gets a Stop button.

Constructor injection works because the runner builds a new instance from the container for every run. Nothing registers the job.

Writing a job that behaves

  • Make it safe to run twice. It is on a timer, an administrator can press Start whenever they like, and a node that dies mid-run leaves work half done.
  • Return a sentence saying what changed. It is stored on the history row and is the only thing anyone reads when they ask what happened. "Reported 412 drafts" answers that; "Done" does not.
  • Honour Stop() if you claim IsStoppable, and watch the cancellation token as well. They are two ways of asking for the same graceful exit, and a job that watches only one will eventually be killed mid-write by the other.
  • Declare no DefaultEvery unless you mean it. Without one the job arrives Manual and runs only when somebody says so, which is the right default for a job just written.

You cannot choose its group, on purpose

New jobs land in Uncategorized and are filed by the administrator from Manage Groups. A category compiled into a package is a folder name the installation cannot change.

Every run is claimed once

A run is claimed by one atomic conditional update, so two nodes produce one run and pressing Start on an already-running job is refused rather than doubled. A claim whose heartbeat goes quiet is broken and recorded as Stopped, not Failed.

Repeats count from the start moment rather than the last finish, so a run that overruns does not walk a 02:00 job into the afternoon.

The WYSIWYG Styles menu

A WYSIWYG template with Styles menu ticked gives its fields a dropdown of presets. Cendia ships four submenus: Typography Presets (large text, small text, blockquote), Buttons (primary, secondary and outline, applied to a link), Heading Styles (H1 to H6 looks on any paragraph) and Grouping Styles (two, three or four columns).

Every entry adds a class, and a class means nothing until your stylesheet styles it. The Starter's site.css styles the shipped ones site-wide, not under .rte: rich text renders inside blocks as well as page bodies, and a block wraps it in its own element, so btn btn-primary has to work wherever the HTML lands. Fonts are not shipped: offer the ones your site actually loads.

"Cendia": {
  "Wysiwyg": {
    "ContentStylesheets": [ "/css/wysiwyg.css" ],
    "StyleGroups": [
      {
        "Title": "Typography Presets",
        "Items": [
          { "Title": "Serif font", "Inline": "span", "Classes": "font-serif" }
        ]
      }
    ]
  }
}

A group whose title matches one already in the menu adds to it, so the entry above joins the shipped presets instead of replacing them. A new title makes a new submenu. To start from nothing, clear the list in code with options.Wysiwyg.StyleGroups.Clear().

Each entry names what it applies to. Inline wraps the selected text (span for a font or a size). Block converts the selected blocks, or wraps them in one element when Wrapper is true. Selector styles an element that is already there, such as a for a button. An entry with none of the three is left out of the menu.

Add a rule for each new class in two places:

  • your site's stylesheet, so it shows on the page
  • a stylesheet in ContentStylesheets, so it shows in the editor and in the menu's preview

Keep that second file to content rules. The editor is a separate document, and a whole site stylesheet would bring the page layout into it.

Credentials for AI

API keys are not an Extensions/ type and not an appsettings value. The next page is how a host chooses a secret store and which vendors Settings may offer.