Global variables
Site-wide {{placeholder}} values — one document holds the phone number, company ID or address; content says {{phone}}; change the value once and it updates everywhere.
Some values appear all over a site and must never drift apart: a phone
number, an e-mail address, a company registration ID, an office address.
Global variables keep each of them in one document; content refers to
them as {{key}} placeholders; the site's build expands the placeholder
wherever it appears. Change the value once, publish, and every page that
mentions it updates.
The CMS gives this first-class authoring support: an insert-variable dropdown on text fields and resolved-value hints under any field that contains placeholders — so authors never have to remember what variables exist or what they're currently set to.
The division of labour
Two parties cooperate, and it's worth being precise about who does what:
- The site owns expansion. Replacing
{{phone}}with the value is the build pipeline's job (or the renderer's) — the CMS never rewrites your content. What lands in git is the placeholder, which is exactly what you want: the content stays source-of-truth-agnostic, and history diffs show "changed the phone number" in one file instead of twenty. - The CMS owns authoring. It knows where the variables live (via config), mirrors the list into the editor, offers insertion, and previews what each token resolves to. It also flags tokens that match no variable — those would render literally on the published site.
Setup
1. The variables document
A site-wide document holding an array of
{key, value} items. Any path works; this is the conventional shape:
# src/content/settings/globals.md (frontmatter-only — or a .yml data file)
variables:
- key: phone
value: '+420 123 456 789'
- key: phoneTel
value: 'tel:+420123456789'
- key: email
value: [email protected]
- key: ico
value: '12345678'Give its collection area: site so it lives in the sidebar's Site
settings group, where editors can add, rename and re-value variables like
any other array field — creating a new variable needs no developer.
2. The root config pointer
Tell the CMS where the document lives, in .norcube/cms/config.yml:
variables:
file: src/content/settings/globals.md
field: variables # the array field; defaults to "variables"| Key | Meaning |
|---|---|
file | Repo-relative path of the variables document. Required. |
field | The frontmatter field holding the {key, value} array. Default variables. |
3. Expansion in your build
The placeholder syntax is {{key}} — optional inner whitespace, keys are
identifier-shaped ([a-zA-Z][a-zA-Z0-9_]*). Match the editor's semantics
in your expander:
- case-insensitive lookup (
{{Phone}}findsphone), - unknown tokens stay as-is — never fail the build over a typo'd placeholder; the editor already flags it to the author,
- expand recursively across every string field in a page's data, at a single central point, so individual components stay placeholder-unaware.
An Astro-flavoured sketch (any framework works the same way):
// lib/tokens.ts
import { getCollection } from 'astro:content';
const TOKEN_RE = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
export async function createTokenExpander() {
// read per render, not at module load — the CMS live preview edits
// the document and the dev server must pick the new values up
const doc = (await getCollection('globals'))[0];
const values = new Map(
(doc?.data.variables ?? []).map((v) => [v.key.toLowerCase(), v.value])
);
const expand = <T>(value: T): T => {
if (typeof value === 'string')
return value.replace(
TOKEN_RE,
(m, k) => values.get(k.toLowerCase()) ?? m
) as T;
if (Array.isArray(value)) return value.map(expand) as T;
if (value && typeof value === 'object')
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, expand(v)])
) as T;
return value;
};
return expand;
}Read values per render, not at module scope
A module-level cache works in a production build but goes stale in the live preview's dev server: the author edits the variables document, the preview syncs it, and the old values keep rendering until a restart. Resolve the document inside the render path.
In the editor
With the config in place, three things light up:
- The insert button. Text, link and paragraph fields grow a small
{}button. It lists every variable — key in monospace, current value underneath — and picking one inserts{{key}}at the cursor, then returns focus with the caret after the token. - Resolved hints. A field whose value contains placeholders shows a
line underneath with each token's current resolution
(
{{phone}} → +420 123 456 789). A token that matches no variable is flagged amber as unknown — it would render literally on the site. - Not inside the variables document. The paster and hints are
suppressed while editing the variables document itself — a
{{token}}inside a variable's own value wouldn't expand (expansion isn't recursive), so offering insertion there would mislead. - Opting a field out. Any text, link or paragraph field can declare
variables: falsein its schema to hide the paster and hints — for identifiers, slugs, or anywhere placeholders make no sense. Typing{{key}}by hand still expands on the site; the knob only controls the editor affordances.
Everything else is ordinary editing: the placeholder is just text in the
field, Publish commits it verbatim, per-field history shows when a
{{token}} was introduced, and the live preview renders the expanded
value (because your site expands it).
Localized variables
Some variables differ per language — an address, opening hours, a tagline
— while others never do (a company ID). The design principle: the token
stays language-neutral, only the value localizes. Content says
{{address}} on every language's page; the value resolves per locale:
translations[pageLocale] → translations[defaultLocale] → valueModel it in the variables document's schema with a checkbox and conditional fields — no special CMS feature needed:
- key: localized
type: boolean
label: Translate per language
default: false
- key: value
type: text
label: Value
# falsy, not `eq: false` — items saved before the checkbox existed have
# no `localized` key at all, and `eq: false` doesn't match a missing key
visibleIf: { field: localized, op: falsy }
- key: translations
type: group
inline: true # render the language inputs in place, no drill-in
label: Translations
visibleIf: { field: localized, op: truthy }
whenHidden: drop # untick the box → the map is purged on save
fields:
- key: cs
type: text
label: Čeština (default)
- key: en
type: text
label: EnglishTicking Translate per language swaps the single Value input for one
input per site language; an empty language falls back to the default
language's value, so a half-translated variable never renders as nothing.
On disk the item carries translations: {cs: …, en: …} instead of
value:.
The editor resolves localized variables against the language of the
page being edited — determined the same way preview deep-linking
determines it (the per-locale folder, or the site default). The insert
dropdown marks localized variables with a languages icon and shows the
value for the current page's language; the hints do the same, and flag a
variable that has no value for that language. Your build's expander
implements the same chain (see the sketch above — index translations
by the page's locale before falling back).
Limits and notes
- Rich-text fields don't have the insert button yet. Typing
{{key}}by hand works everywhere — expansion is the site's job — the dropdown just hasn't been built into the rich-text toolbar. - Values are plain strings. For a value that needs two renderings
(display vs. href), use two variables — e.g.
phoneandphoneTel— rather than encoding logic into one. - The editor reads the variables document from the connected branch; a teammate's unpublished draft of the document isn't visible to your session until it's published.
Where to next
Site-wide documents
Settings, navigation, design tokens — content that isn't a page. Declare a collection with `area: site` and the editor treats it like the settings surface it is.
AI schema generation
The CMS scans your repo, observes what's actually in your frontmatter, and asks an LLM to draft a working schema you can use immediately — full bootstrap or single-block.