# Editable area markers (CMS)

> Use data-cms attributes to let the business owner edit text, photos, news lists, article pages, and multiple languages.

URL: https://siteon.dev/docs/cms

Siteon is infrastructure for AI-built websites: it adds hosting, a CMS your client can edit, contact forms, analytics and control from AI agents (CLI and MCP) around static HTML built with AI or by hand, so it is ready to ship.

## How it works

Add a `data-cms` attribute to an HTML element and that spot becomes editable from the dashboard. What can be edited is declared in [schema.json](https://siteon.dev/docs/schema).

- The text inside the element is the **default text**. When there is no value (not entered yet, or the CMS can't be reached), the HTML stays as is
- Values are escaped and inserted as text. Editors' input can never inject HTML or scripts
- Values are embedded by Siteon hosting or the Cloudflare Pages relay (before delivery), or by `data-embed` (at render time) ([Hosting and delivery](https://siteon.dev/docs/hosting))

## Marker reference

| Attribute | Effect |
|---|---|
| `data-cms="key"` | Replaces the text inside the element |
| `data-cms-html="key"` | Same replacement, with line breaks turned into `<br>` |
| `data-cms-src="key"` | Replaces `src` on `img` / `video` / `source` |
| `data-cms-attr="attribute:key"` | Replaces any attribute (e.g. `content:title`, `href:url`) |
| `data-cms-blocks="key"` | Renders an article body (a sequence of blocks) inside the element |
| `data-cms-hide-empty` | Removes the whole element when the value is an empty string (only with `data-cms` / `data-cms-html`) |
| `<template data-cms-list="k" data-cms-limit="n">` | Repeats its contents for each item in the list (up to `n` items) |
| `<template data-cms-list="k" data-cms-filter="f=v">` | Repeats only for items matching the condition |
| `<template data-cms-empty="k">` | Shows its contents only when the list is empty |
| `<template data-cms-entry="k">` | On an article page, renders that single article |
| `<template data-cms-each="k">` | Repeats for each element of an array (tags, etc.) |
| `data-cms-fallback="k"` | Removes the element once list `k` arrives (a fallback shown when it doesn't) |

## Text

Values in a group (`groups` in [schema.json](https://siteon.dev/docs/schema)) are referenced as `<group>.<field>`.

```html
<dd data-cms="hours.days">Mon–Sat</dd>

<!-- Line breaks become <br>. If empty, the whole box disappears -->
<p class="notice" data-cms-html="hours.notice" data-cms-hide-empty>No temporary closures at the moment.</p>
```

## Photos

Photos in lists and articles (fields with `type: "image"`) arrive as absolute URLs. Put them into `src` with `data-cms-src`.

```html
<template data-cms-list="news">
  <article>
    <img data-cms-src="thumbnail" src="/img/placeholder.jpg" alt="">
    <h3 data-cms="title">Article title</h3>
  </article>
</template>
```

URL attributes such as `href` and `src` never receive values starting with `javascript:` / `data:` / `vbscript:` (the default value stays). `data-cms-attr` cannot rewrite `on…` event attributes, `srcdoc`, or `formaction`.

## Lists such as news

Collections (`collections` in schema.json) are repeated with `<template data-cms-list>`. Inside it, `data-cms` takes the field names of a single item.

```html
<ul class="news">
  <template data-cms-list="news" data-cms-limit="3">
    <li>
      <time data-cms="date">2026-01-01</time>
      <a data-cms-attr="href:url" href="/news/"><span data-cms="title">Article title</span></a>
    </li>
  </template>

  <!-- Shown only when there are no items -->
  <template data-cms-empty="news">
    <li>No news yet.</li>
  </template>

  <!-- Removed once the list arrives. Stays if the CMS can't be reached -->
  <li data-cms-fallback="news"><span>Our website is now live</span></li>
</ul>
```

- List order follows `sort` in schema.json (e.g. `"date desc"`). Without it, items follow the order arranged in the dashboard
- A single list holds up to 100 items, newest first
- Link to articles with `data-cms-attr="href:url"`. `url` is not in schema.json; **the relay** adds it to each article as `/news/<id>` (with the language prefix, as an absolute URL). It is not added with `data-embed`
- `data-cms-limit` accepts integers only (`"3"`). Anything else is treated as no limit and every item is shown

## A page per article

Per-article URLs (`/news/<id>`) are rendered **by the relay** using `news/_entry.html` as the template. You don't need one file per article. This is available only on sites using the Cloudflare Pages relay.

```text
public/news/_entry.html     Article page template
public/news/_404.html       Page for IDs that don't exist (optional)
public/en/news/_entry.html  One per language
```

```html
<template data-cms-entry="news">
  <h1 data-cms="title">Article title</h1>
  <time data-cms="date">2026-01-01</time>
  <div data-cms-blocks="body"></div>
</template>
```

- URLs are `/news/<id>` or `/<language>/news/<id>`. `<id>` is lowercase letters and digits only, up to 32 characters
- IDs not among published articles return 404 (`news/_404.html` is served if present)
- The path and collection key come from the relay's `CMS_NEWS_PATH` environment variable (default `news`)

### Per-entry pages for other collections (Siteon hosting)

On Siteon hosting, collections other than news (talent, menu items, case studies and so on) can have a page per entry too. Put `_entry.html` in a folder named after the collection key.

```text
talents/_entry.html      Template for each talent's page (inside: <template data-cms-entry="talents">)
en/talents/_entry.html   One per language
talents/_404.html        Page for IDs that don't exist (optional)
```

- URLs are `/talents/<id>` or `/<language>/talents/<id>`. Only in languages that have the template, each list item gets a `url` and the pages are listed in sitemap.xml
- Collections without a template still have no per-entry pages
- The pre-publish check (`siteon check` / `check_site`) warns when a list links to entries but there's no template, or when the template lacks `data-cms-entry`

### Put a template in `<head>` too for `<title>` and OGP

Only the inside of the template can read "that single article" from `data-cms-entry`. If you write `<title data-cms="title">` directly in `<head>`, it reads the **site-wide value** instead of the article's. Since it isn't visible on the page, you typically only notice when the link is shared on social media.

```html
<head>
  <template data-cms-entry="news">
    <title data-cms="title">News | Store name</title>
    <meta name="description" data-cms-attr="content:lead" content="News from Store name.">
    <meta property="og:title" data-cms-attr="content:title" content="News | Store name">
    <meta property="og:image" data-cms-attr="content:thumbnail" content="../assets/hero.jpg">
    <meta property="og:url" data-cms-attr="content:url" content="">
    <link rel="canonical" data-cms-attr="href:url" href="">
  </template>
  <!-- Title that remains if the CMS can't be reached and the template isn't expanded -->
  <title data-cms-fallback="news">News | Store name</title>
</head>
```

A `<title>` with `data-cms-fallback` is removed once the template is expanded. Without it, you'd get two `<title>` elements. An article's `url` is absolute, so it can go straight into `og:url` and `canonical`.

## Article body (blocks)

Fields with `type: "blocks"` are rendered with `data-cms-blocks`. Every class on the output starts with `sk-`, and **styling is up to your site's CSS** (no stylesheet is provided).

```css
.sk-h  { font-size:1.05rem; margin:2rem 0 .5rem; }   /* Heading */
.sk-p  { margin:0 0 1rem; }                          /* Paragraph */
.sk-q  { border-left:3px solid #e5e7eb; padding-left:1rem; color:#6b7280; }
.sk-ul, .sk-ol { margin:0 0 1rem; padding-left:1.4rem; }
.sk-img img { width:100%; height:auto; }
.sk-img figcaption { color:#6b7280; font-size:.8rem; }
.sk-video { width:100%; }
.sk-embed { position:relative; padding-top:56.25%; }
.sk-embed iframe { position:absolute; inset:0; width:100%; height:100%; border:0; }
.sk-hr { border:0; border-top:1px solid #e5e7eb; }
.sk-pdf, .sk-card, .sk-video-link { display:inline-block; }

/* Text colors. Keep the class names. Adjust the color values to your site's palette */
.sk-c-red   { color:#c0392b; }
.sk-c-gold  { color:#b8860b; }
.sk-c-gray  { color:#6b7280; }
.sk-c-black { color:#111827; }
.sk-c-green { color:#15803d; }
.sk-c-blue  { color:#1d4ed8; }
```

Editors can use the following syntax in the body.

| Syntax | Output |
|---|---|
| `**bold**` | `<strong>` |
| `[text](https://…)` | A link that opens in a new tab (http/https only) |
| `{color:red}text{/color}` | `<span class="sk-c-red">` (six colors: red / gold / gray / black / green / blue) |
| A bare `https://…` | Linked automatically |

If you forget to define `.sk-c-*`, color markup has no visible effect. On sites that use colors, define all six up front.

## Tags

Set a collection field to `"type": "tags"` to tag articles (up to 20 per article, up to 40 characters per tag, `/` `#` `?` not allowed). Tags are not translated.

**Tags on each article** — output them one by one with `data-cms-each`. The relay turns each tag into `{tag, url, color}`, so you can write the name and a link to its tag page.

```html
<template data-cms-each="tags">
  <a data-cms-attr="href:url" href="#">
    <span class="sk-tag" data-cms-attr="data-color:color" data-cms="tag">Tag</span>
  </a>
</template>
```

**Tag list (navigation)** — tags in use arrive as a list named `<collection>_tags` (e.g. `news_tags`), each with `{tag, count, url, color}`.

```html
<template data-cms-list="news_tags">
  <a data-cms-attr="href:url" href="#"><span class="sk-tag" data-cms="tag">Tag</span></a>
</template>
```

**Tag pages** — `/news/tag/<tag>/` is rendered by the relay using `news/_tag.html` as the template. The `news` list arrives already filtered by that tag. The display name is `news.tag`. A tag that doesn't exist does not return 404; it returns an empty list (so `data-cms-empty` is shown).

```html
<h1>Tag: <span data-cms="news.tag">Tag name</span></h1>
<ul>
  <template data-cms-list="news">
    <li><a data-cms-attr="href:url" href="./"><span data-cms="title">Title</span></a></li>
  </template>
  <template data-cms-empty="news"><li>No news with this tag yet.</li></template>
</ul>
```

Tag colors are set under "Settings → Advanced → Manage tags" in the dashboard. Tags without a color get no `data-color` attribute and use the default `.sk-tag` look.

```css
.sk-tag { display:inline-block; padding:.1rem .5rem; border-radius:999px;
          font-size:.75rem; background:#f3f4f6; color:#374151; text-decoration:none; }
.sk-tag[data-color=red]   { background:#fdecea; color:#c0392b; }
.sk-tag[data-color=gold]  { background:#fdf6e3; color:#8a6d1f; }
.sk-tag[data-color=gray]  { background:#f3f4f6; color:#4b5563; }
.sk-tag[data-color=black] { background:#1f2937; color:#fff; }
.sk-tag[data-color=green] { background:#e8f5ec; color:#15803d; }
.sk-tag[data-color=blue]  { background:#e8effd; color:#1d4ed8; }
```

Writing `data-cms-filter="tags=autumn"` filters outside tag pages too (array fields match on "contains", single values on "equals").

## Category badges

`select` options can have colors ([schema.json](https://siteon.dev/docs/schema#options-for-select)). Besides the stored value, the public JSON includes `<field>_label` (the display text) and `<field>_color` (the color name; omitted for options without a color).

```html
<span class="sk-badge" data-cms="category_label" data-cms-attr="data-color:category_color"></span>
```

Write the `.sk-badge[data-color=…]` CSS for the six colors in the same form as `.sk-tag`.

## Multiple languages

1. List languages in `locales` in schema.json (e.g. `["ja", "en"]`). The first is the primary language, and editors enter content in it
2. Text fields (`text` / `textarea` / `blocks`) are translated automatically by AI after saving (on plans with AI translation). Add `"translate": false` to fields that shouldn't be translated (times, dates, categories, etc.)
3. Fields with no translation, or an empty one, show the primary-language value
4. Prepare a page per language and write `language:prefix` pairs in the relay's `CMS_LOCALES` (e.g. `ja:/,en:/en`). The language is determined by the URL alone; `Accept-Language` is ignored

With `data-embed`, content is fetched in the language from `<html lang>` or the script's `data-locale`.

Whether AI translation is available, and how many times, depends on the plan ([Plans and limits](https://siteon.dev/docs/plans)).

## Rules for writing markers

Breaking these produces no error; the page just renders wrong or nothing happens. Start here when something doesn't work.

- **Don't nest an element with the same tag name inside a marked element.** `<div data-cms="x"><div>…</div></div>` breaks. Use a different tag such as `<span>`
- **One marker per element.** If you add two, the later one wins
- **Only one `data-cms-attr` per element.** Writing two, like `href:url data-color:color`, writes neither. Split them across elements
- **Write tag and attribute names in lowercase.** `<DIV DATA-CMS="k">` is ignored
- **Don't put markers inside `<script>` or `<style>`.** Their contents would be rewritten (inside HTML comments is safe)
- **Don't put another `data-cms-list` inside `<template data-cms-list>`.** To repeat within a single item, use `data-cms-each`
- **`data-cms-empty` doesn't appear when `data-cms-filter` leaves zero items.** `data-cms-empty` only checks whether the list itself is empty
- **Make the static text make sense on its own.** It is what gets published when the CMS can't be reached
