Make a page editable

A page becomes editable in three steps: give it a page key, register the tag helpers, and wrap each editable part in a slot tag with its default content inside. The examples on this page come from the sample site in the SunBird repository (samples/HCI.SunBird.SampleSite), which is SunBird's own home page built with SunBird.

This guide assumes SunBird is already registered with a database. See Getting started.

1. Give the page a key

Put [SunBirdPage] on the page's PageModel:

[SunBirdPage("home", Title = "SunBird: a calm CMS for hand-built sites", Description = "SunBird lets editors change the words and pictures on a hand-built Razor site, on the live page, without touching its design.")]
public sealed class IndexModel : PageModel
{
}

The key is an opaque identifier, not the URL. It is how SunBird finds the page's content, so changing the page's route or file name keeps its content, and changing the key loses it. Keys are 1 to 100 characters of ASCII letters, digits, -, _ and ., and each page's key must be unique on the site.

A page with no PageModel can set the key in the view instead:

@page
@attribute [HCI.SunBird.Web.SunBirdPage("contact")]

2. Register the tag helpers

Add one line to Pages/_ViewImports.cshtml:

@addTagHelper *, HCI.SunBird

3. Mark the editable parts

Wrap each editable part in a slot tag. Each slot needs a key that is unique on its page. The content inside the tag is the default, which is shown until an editor publishes a value:

<h1><sb-text key="hero-title">Build calm, fast websites</sb-text></h1>

<sb-html key="hero-intro">
    <p>We design and build websites for small organisations.</p>
</sb-html>

<sb-image key="hero-image" src="/img/hero.jpg" alt="Our office in York"
          class="hero-image" width="1200" height="600" loading="lazy" />

What each tag renders

The slot tags themselves never reach the browser.

Tag With a published value Otherwise
<sb-text> The text, HTML-encoded. An editor typing <b> gets the characters <b>, not bold text. The content inside the tag.
<sb-html> The HTML SunBird wrote when it was saved. The content inside the tag.
<sb-image> An <img> with the published image and its alt text. An <img> with the tag's own src and alt.
<sb-link> An <a> with the published text and address. An <a> with the tag's own href and content.
<sb-file> An <a> to the published document, with the published text. An <a> with the tag's own href and content.
<sb-icon> An inline <svg> of the published icon. An inline <svg> of the icon in the tag's own name.
<sb-table> A <table> with the published columns, sections and rows. A <table> with the tag's own content.

Put the slot inside the element that styles it (<h1><sb-text …></h1>), not around it. <sb-image> keeps every attribute other than key, src and alt (for example class, width, height, loading), so the page's CSS and layout stay the same whatever the editor picks.

Public page views only read. Nothing is written to the database until an editor saves. Each page's published content is read once and then cached in memory until the page is next published. Every copy of the site sharing the database, such as staging and production, checks every 5 seconds whether anything was published elsewhere, so a change shows on all of them within seconds.

<sb-link> makes a link an editor can change, such as a call-to-action button. The content inside is its default text and href its default address:

<sb-link key="cta" href="/contact" class="button">Get a quote</sb-link>

It renders an <a> that keeps every attribute other than key and href, so it can be styled like a button. Editors change its words on the page and choose where it goes in edit mode:

  • A page of the site: any page with [SunBirdPage] and an address without parameters, or a page editors added. The page is saved by its key, so the link follows it if its address changes. While the page is unpublished or deleted, the link uses its href from the markup.
  • An address: one that starts with https://, http://, mailto:, tel: or / (a page of this site). Anything else, such as javascript:, is refused.

Links work inside <sb-scope> and <sb-repeater> like any slot, for example a button in each entry of a list.

Downloads

<sb-file> makes a download link, such as a form or a leaflet, whose document editors choose. The content inside is its default text and href its default address:

<sb-file key="referral-form" href="/docs/referral-form.pdf" class="download" show-size>Referral form</sb-file>

It renders an <a> that keeps every attribute other than key, href and show-size. Editors change its words on the page; clicking it in edit mode opens the media rail, where they pick a PDF from the media library or upload one. Once a document is chosen the link goes to /sunbird/media/{id}/{file-name}, which opens the PDF in the visitor's browser.

show-size adds the document's type and size after the text, in a span you can style:

<a href="/sunbird/media/…/Referral%20form.pdf" class="download">Referral form <span class="sb-file-size">(PDF, 147 KB)</span></a>

Until an editor chooses a document, the link uses its href and content from the markup, with no size. Only PDF documents can be chosen.

Tables

<sb-table> makes a table whose cells editors change, and whose rows, columns and sections they add, remove and move, such as a price list or opening hours. The tag becomes the <table>, and the rows inside it are the default:

<sb-table key="prices" class="price-list">
    <thead>
        <tr><th scope="col">Treatment</th><th scope="col">Description</th><th scope="col">Price</th></tr>
    </thead>
    <tbody>
        <tr><th scope="rowgroup" colspan="3">Check-ups</th></tr>
        <tr><th scope="row">Examination</th><td>A full check of teeth and gums</td><td>£25</td></tr>
        <tr><th scope="row">X-ray</th><td>Small, one or two</td><td>£15</td></tr>
    </tbody>
    <tbody>
        <tr><th scope="rowgroup" colspan="3">Hygiene</th></tr>
        <tr><th scope="row">Scale and polish</th><td>Half an hour with the hygienist</td><td>£60</td></tr>
    </tbody>
</sb-table>

Each <tbody> is a section. A section's first row is its heading when it is one cell across the table (with scope="rowgroup", or a colspan of every column); a section can also have no heading, so a plain table is one <tbody>. The <thead> row holds the column headings.

A published table is always written in exactly this shape, and nothing else, so style it by structure rather than by classes. Write the default in the same shape, as above, and one set of rules styles both:

Part Rendered as Select it with
Column headings <thead><tr><th scope="col"> thead th
A section <tbody> tbody
A section's heading <tr><th scope="rowgroup" colspan="3"> th[scope="rowgroup"]
A row's first cell <th scope="row"> th[scope="row"]
The other cells <td> td, td:last-child for a price column

The table keeps every attribute other than key, such as class. SunBird adds no classes, styles or <caption>, and leaves out the <thead> when every column heading is blank. Classes on rows and cells in the default are not kept once an editor saves the table.

Cells hold plain text, like <sb-text>. A table has 1 to 12 columns, at least one row in every section and at most 200 rows in all, and a cell holds at most 500 characters; a save that breaks these is refused. Tables work inside <sb-scope>, <sb-repeater> and fragments like any slot.

Icons

<sb-icon> shows an icon editors can change, such as the icon on a service or feature card. name is the default icon, by its name in Google's Material Symbols (Outlined), such as home, call or local_shipping:

<sb-icon key="icon" name="home" class="feature__icon" />

It renders the icon as an inline <svg> that keeps every attribute other than key and name:

<svg viewBox="0 -960 960 960" width="1em" height="1em" fill="currentColor" class="feature__icon" aria-hidden="true"><path d="…"/></svg>
  • Size and colour come from CSS, as for text: the icon is 1em square in currentColor, so font-size and color on its parent work, or set width, height and fill on its own class.
  • Screen readers skip it, as most icons sit beside words that say the same. If the icon is the only thing that says what something is, give the tag an aria-label; it then renders with role="img".
  • Nothing else loads. SunBird carries the whole Material Symbols set (about 4,000 icons, weight 400, unfilled, 24px optical size) inside its DLL and writes only the one icon into the page: no icon font, no script and no request to Google. The icons are Google's, under the Apache License 2.0, which SunBird ships at /_content/HCI.SunBird/icons/material-symbols-LICENSE.txt.

Clicking an icon in edit mode opens the icon rail, where editors search the library by name or by tag (house finds home) and click the one they want. It is saved with the page like any slot. A name that is not in the library is a slot error. If a saved icon is ever dropped from a later version of the library, the page shows its name instead.

Icons work inside <sb-scope>, <sb-repeater> and shared fragments like any slot.

Titles and descriptions

Set the page's default title and meta description on the attribute, and put <sb-seo /> in the layout's <head> instead of a <title>. Editors can then change both in edit mode:

[SunBirdPage("contact", Title = "Contact us", Description = "Call, email or visit our York office.")]

See SEO page settings.

Slots in partials

Slots can be in the page's own view and in any partial the page renders. A slot belongs to the page being served, not to the file it is written in. If a partial with hero-title in it is used on three pages, each page has its own hero-title, edited separately.

Using a partial more than once on a page

Slot keys must be unique on a page, so a partial used twice would give two slots the same key. Wrap each use in <sb-scope>, which puts its key in front of the keys inside it. The sample site's "How it works" section uses one partial for its three steps:

<ol class="steps">
    <li><sb-scope key="step-1"><partial name="_Step" model="1" /></sb-scope></li>
    <li><sb-scope key="step-2"><partial name="_Step" model="2" /></sb-scope></li>
    <li><sb-scope key="step-3"><partial name="_Step" model="3" /></sb-scope></li>
</ol>

The partial uses plain keys:

@model int
@* One step of "How it works". The page wraps each use in <sb-scope>, so the keys below are per step. *@
@{
    var (title, body) = Model switch
    {
        1 => ("Mark the editable parts", "Wrap a heading, a paragraph or a picture in a slot tag, with your placeholder inside it."),
        2 => ("Give the page a key", "Put [SunBirdPage(\"home\")] on the PageModel. The key is not the URL, so pages can move without losing content."),
        _ => ("Hand it over", "Your client signs in, edits on the live page, and publishes when they are happy. Every version is kept."),
    };
}
<article class="step">
    <span class="step__number" aria-hidden="true">@Model</span>
    <h3><sb-text key="title">@title</sb-text></h3>
    <sb-html key="body"><p>@body</p></sb-html>
</article>

The slots are stored as step-1.title, step-1.body, step-2.title and so on. Scopes can be nested (outer.inner.title). The full key, including every scope, must still be at most 100 characters. Like the slot tags, <sb-scope> renders only its content.

Where slots cannot go

Slots belong to pages, so they are refused in:

  • Layouts, and partials rendered by a layout. Header, footer and other layout content are managed in code, except a fragment (<sb-fragment>), whose slots may be anywhere.
  • Sections (@section) that a layout renders. These are rendered while the layout runs, so SunBird cannot tell them apart from the layout. Put the slot in the page body instead.

Mistakes and how they show up

These are mistakes in the page's code:

  • a slot on a page without [SunBirdPage]
  • a slot without a key, or with an invalid key
  • two slots with the same key on one page
  • a slot in a layout or a section, outside a fragment

Every one is logged as an error. What the page shows depends on the SunBird:ShowSlotErrors setting:

  • On: the faulty slot is replaced by a red box that says what is wrong and how to fix it, for example SunBird slot error: <sb-text key="title"> is used more than once on page 'home'…. The rest of the page renders as normal, so you can see every problem on a page at once.
  • Off: the slot shows its markup default, so a mistake never spoils a live page.

It is on in Development and off everywhere else unless you set it. Turn it on for staging, to check a release visually before it goes live, and leave it off on the live site. See Settings.