Build on it
Theme development
Generated from THEME_DEVELOPMENT.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.
A theme controls how the public site looks and, since template slots landed, how it is structured. Themes come in two strengths:
| Tokens-only theme | Template theme | |
|---|---|---|
| Changes | colors, typography, custom CSS | markup + colors + typography |
| Written as | a defineTheme({...}) module with no components | the same, plus .astro overrides |
| Effort | minutes | as much as you want |
Both are build-time modules, like code plugins: Astro compiles the server, so themes are explicit imports, not uploads. Markup is deliberately not installable at runtime — see the extensibility model.
Two ways to ship a theme
Declarative (theme.json) | Bundled (defineTheme) | |
|---|---|---|
| Install | Paste JSON into Admin → Themes, or POST /api/themes/install. No rebuild. | Add src/themes/<id>/, one import in src/themes/index.ts, redeploy. |
| Tokens | ✅ | ✅ |
| Stylesheet | ✅ | ✅ |
| Patterns | ✅ | ✅ |
| Replace Header/Footer/Sidebar/PostCard/PostArticle/Home/PageArticle/Breadcrumbs | ❌ | ✅ |
Start declarative. It covers colour, type, shape, a stylesheet and ready-made page layouts, which is most of what a theme is — and it installs on a running site without a deploy. Reach for a bundled theme only when you need to change the page structure, which needs real components and therefore a build.
A declarative theme
{
"id": "sunset",
"name": "Sunset",
"version": "1.0.0",
"author": "You",
"tokens": {
"colors": { "primary": "#e2571e", "secondary": "#8b1e3f" },
"style": { "radius": "lg", "density": "roomy" },
"colorScheme": "auto"
},
"css": ".ab-hero { background: linear-gradient(var(--primary-color), var(--secondary-color)); }",
"patterns": [
{
"name": "splash",
"label": "Splash",
"description": "Full-bleed hero.",
"html": "<div class=\"ab-hero ab-align-center\"><h2>Headline</h2></div>"
}
]
}
Four rules, all enforced at install rather than discovered on a live site:
- Colours must be hex. Token values are written into
/theme.cssas--primary-color: <value>, so a value carrying;or}would escape the declaration and become arbitrary CSS. A stored value may select CSS; it may never be CSS. stylevalues must be token KEYS, not CSS."radius": "lg"— not"14px". The key names a pre-authored block; the raw value is refused.- Every pattern must survive the content sanitizer byte-for-byte. One that does not is refused, and the response shows your markup next to what the sanitizer returned, so you can see exactly what was dropped.
cssis filtered but NOT namespace-scoped. Unlike a plugin, restyling the whole site is your job.@import,</style>escapes andjavascript:URLs are removed; anything over 100 KB is refused whole rather than truncated (a cut inside@media (…) {swallows every rule after it).
Your tokens are the theme’s defaults. Once installed, the operator’s customizations win — and upgrading to a new version keeps them, because a version bump silently reverting someone’s colours is the theme equivalent of overwriting their content.
Uninstalling is refused while the theme is active: activate something else first, so the replacement is the operator’s choice rather than ours.
Anatomy
src/themes/<your-id>/
index.ts # defineTheme({...}) — required
Header.astro # optional slot overrides
PostCard.astro
// src/themes/my-theme/index.ts
import { defineTheme } from 'astrobaas/core';
import Header from './Header.astro';
import PostCard from './PostCard.astro';
export default defineTheme({
id: 'my-theme', // stable, kebab-case, unique
name: 'My Theme',
description: 'What it looks like.',
version: '1.0.0',
author: 'You',
settings: { // DEFAULT tokens (operators can customize)
colors: { primary: '#111827', secondary: '#6B7280', accent: '#B45309',
background: '#FFFFFF', text: '#111827' },
typography: { headingFont: 'Playfair Display', bodyFont: 'Inter', fontSize: '17px' },
},
components: { Header, PostCard }, // omit for a tokens-only theme
});
Register it in src/themes/index.ts — one import, one
array entry — then rebuild. It appears in Admin → Themes, ready to activate.
Slots
| Slot | Renders | Props |
|---|---|---|
Header | Site header on every public page | siteTitle, locale |
Footer | Site footer on every public page | siteTitle, social, locale |
PostCard | One post in a listing (blog index) | post (with author, category, image, date, readTime resolved) |
PostArticle | The whole single-post view | post, contentHtml, author, category, date, readTime |
Sidebar | Optional aside; default renders nothing | context: 'archive' | 'post' | 'page' | 'home', locale |
Home | The STOCK front page (shown while no CMS Page is the designated home). Owning it means owning the first screen: hero, latest posts, whatever the theme’s identity calls for. | siteTitle, siteTagline, posts: PostCardData[] (recent, newest first), locale |
PageArticle | A CMS Page at /{slug} — and at / when designated the home. Separate from PostArticle because a Page has no byline, date or category, and usually wants different typesetting. isHome lets a theme drop the title band when the Page is the front door. | post, contentHtml (sanitized — render with set:html), isHome |
Breadcrumbs | The trail above the content on every public page. The array arrives already built by the route — a slot must never fetch — and the SAME array is serialized into the page’s BreadcrumbList structured data, so a reader and a crawler can never be told different things. Render nothing below two items: a lone “Home” that links to the page you are on is noise. The last item is the current page and carries no href; mark it aria-current="page". | items: BreadcrumbItem[] ({ name, href? }) |
Colours come from classes, never from
style="...". The production CSP is hash-based with no'unsafe-inline'and no'unsafe-hashes', and hashes never cover style ATTRIBUTES — sostyle="color: var(--text-color)"is dropped silently by the browser. It looks right inastro dev(no CSP) and loses its colour in production, which is the worst shape a bug can take. Use the sharedab-*token utilities (ab-ink,ab-muted,ab-accent,ab-on-accent,ab-surface,ab-bg-accent,ab-border-ink,ab-heading-font) or a scoped<style>block in your own.astrofile — Astro hashes those, so they work.
Prop types live in src/core/theme-slots.ts and are
exported from astrobaas/core, so you get full inference:
---
import type { PostCardProps } from 'astrobaas/core';
const { post } = Astro.props as PostCardProps;
---
<article class="my-card">
<a href={`/blog/${post.slug}`}>{post.title}</a>
<p>{post.excerpt}</p>
</article>
You override what you want; the rest is inherited
Every slot you don’t declare falls back to the built-in component. That’s what keeps themes forward-compatible: when a new slot is added, your theme keeps working and simply inherits the new default. A theme whose module is missing from a build degrades to the stock look rather than erroring.
Design tokens
settings are your theme’s defaults. Operators can override colors,
typography, and custom CSS in the customizer, and their values win — so read
colors from the CSS custom properties rather than hard-coding hex values:
.my-card a { color: var(--primary-color); }
.my-card { font-family: var(--body-font), sans-serif; }
All tokens are served from /theme.css (an external stylesheet, so the strict
hash-based CSP applies unchanged — see below).
Colour
--primary-color, --secondary-color, --accent-color, --background-color,
--text-color, --surface-color, --muted-color, --border-color,
--on-primary, --success-color, --warning-color, --danger-color.
--on-primary is derived from the primary colour’s luminance when the operator
has not set it, so text on a brand-coloured button stays readable whether the
brand is navy or lemon.
Typography
--heading-font, --body-font, --font-size-base, --letter-spacing,
--line-height, --heading-line-height, --heading-weight, --h1, --h2,
--h3.
The chosen families are also fetched — the layout emits a webfont <link>
for them. Do not assume a family is installed locally.
Shape, space and layout
--radius-sm|md|lg|pill, --space-1|2|3|4|6|8, --section-y,
--shadow-sm|md|lg, --container, --measure,
--btn-bg|fg|border|radius, --header-align|direction|pad|border.
Where these come from
Most are enum scales, not free-form values. The operator picks a key
(radius: 'lg', density: 'roomy', shadow: 'strong', containerWidth,
typeScale, headingWeight, buttonStyle, headerStyle) and
src/lib/theme-tokens.ts maps that key to a pre-authored block of declarations.
This matters for a theme author in two ways:
- A stored value can never be CSS, only select CSS. There is no sanitiser to get wrong. An unrecognised key falls back to the group default.
- Every key in a scale emits the same token names, so you can rely on a
token existing regardless of which key the operator picked. A test enforces
that, and a token with no
var()consumer anywhere insrc/fails the suite — this project shipped four separate “setting that changes nothing” bugs before that check existed.
Dark mode
colorScheme is 'light' | 'dark' | 'auto'. Under auto, /theme.css emits
a prefers-color-scheme block and an explicit [data-theme] override, so a
visitor’s toggle wins in both directions. The toggle persists to a cookie which
the server reads, stamping data-theme on <html> before the HTML is sent —
no flash, and no inline script, which the hash-based CSP would refuse anyway.
A theme may author its own darkColors; anything it leaves out is derived from
the light palette, so a partial dark palette is legal and useful.
Presets
src/themes/presets.ts holds complete named bundles (colours + type + shape +
density + dark palette) surfaced as one-click options in the customizer. A
preset is expressed in the same flat payload the manual controls post, so there
is one write path and one validator.
Styling sections
Authors build page layout from a palette of sections — hero, columns, card,
CTA, note, media, gallery, spacer — which the editor inserts as allow-listed CSS
classes on ordinary tags (<div class="ab-hero ab-align-center">). There is no
block tree: content is the same sanitized HTML string it has always been, so
sections arrive inside contentHtml and need nothing special to render.
The default styles live in src/styles/sections.css and are written entirely
in design tokens (var(--primary-color), var(--radius-md), var(--space-6),
…), so changing a preset restyles every section without a theme writing any CSS.
One naming rule worth stating plainly, because an earlier draft of this document
got it wrong and the result is a silent no-op: the ab- prefix belongs to
section classes (.ab-hero, .ab-columns), never to custom properties.
There is no --ab-* token namespace — a rule written against
var(--ab-radius-md) resolves to nothing, applies nothing, and reports nothing.
The real token names are the ones sections.css itself uses: --primary-color,
--background-color, --surface-color, --muted-color, --border-color,
--radius-md / --radius-lg, --space-3 / --space-4 / --space-6, and
--section-y.
To go further, override the classes from your theme’s stylesheet:
.ab-hero { padding-block: 6rem; text-align: start; }
.ab-columns { gap: var(--space-6); }
Two constraints are load-bearing:
- Never use a Tailwind utility in section CSS. Tailwind’s JIT scans source files, and stored post HTML is not one — a utility class named only in the database is never generated, so it silently does nothing.
- Scope parts to their parent (
.ab-columns > .ab-col, not.ab-col). A section’s parts can be orphaned by editing, and an orphan should degrade to plain readable content rather than broken layout.
A theme can restyle a section but cannot restructure it, because there is no
tree to walk. The full vocabulary — and the generated sanitizer allow-list that
guarantees the editor and the save path agree — is src/core/sections.ts.
Shipping a stylesheet and patterns
defineTheme takes two more optional fields:
css— a stylesheet appended to/theme.css, after the token declarations (sovar(--primary-color)is already defined) and before the operator’s custom CSS (so an operator can always override you). Serve-time filtering strips@importand escape attempts. If it exceeds 100 KB it is refused whole rather than truncated, because a cut inside@media (...) {swallows every rule after it — a stylesheet that breaks in a way that reads as a CSS bug is worse than one that is visibly missing.patterns— named arrangements of sections offered in the editor alongside the built-in ones:
patterns: [{
name: 'split-hero',
label: 'Split hero',
description: 'Headline beside an image.',
html: '<div class="ab-media ab-media-right">…</div>',
}]
Each is checked against the sanitizer at resolve time and dropped if it does
not survive byte-for-byte, with the reason shown in Admin → Tools. So a
pattern referencing a section this build does not define is reported by name
rather than silently losing a region on save. Your names are namespaced
(<themeId>--<name>), so you cannot shadow a built-in pattern.
Rules that keep the site safe and fast
- No inline
<style>or<script>. The production CSP hashes what the build bundles and allows nothing else; astyle="..."attribute or inline<style>is silently dropped by the browser. Use classes, a scoped<style>block in your.astrocomponent (Astro hashes those), or CSS custom properties set via CSSOM in a bundled script. contentHtmlis already sanitized. Render it withset:htmland do not re-sanitize, unescape, or wrap it in another sink.- Escape nothing by hand. Astro escapes interpolations; only
set:htmlbypasses that, and the only value you should pass it iscontentHtml. - Don’t fetch in a slot. Slots render per request; the page already resolved the data. Extra queries multiply per post.
What themes deliberately cannot do (v1)
- Add routes or pages. A theme presents existing content; routing is the app’s concern. Need a new page type? That’s a code change or a custom content type (see PLUGIN_DEVELOPMENT.md).
- Change the admin. Slots cover the public site only.
- Ship server logic. That’s what plugins are for.
- Be installed at runtime. Markup is code; code is build-time. The runtime-installable tier is declarative plugin manifests, which can add CSS but never templates.
Page-level layout slots (a full ArchiveLayout/PostLayout) are not in v1.
PostArticle already covers the single-post view; if you need more, open an
issue describing the case — adding a slot is additive and cheap.
Testing your theme
npm run build # type-checks your slots against the prop contracts
npm run dev # activate it in Admin → Themes
npm test # unit + smoke; smoke asserts theme switching works
The admin’s theme card shows exactly which slots your theme overrides, which is the quickest check that registration worked.
Bundled examples
default— overrides nothing. The stock site, and the reference for what each default slot renders.editorial— a serif magazine. Overrides seven of the eight slots (Header,Footer,Home,PostCard,PostArticle,PageArticle,Breadcrumbs) and inheritsSidebar. The reference for a template theme.marquee— a street poster: display caps, four-pixel rules, one loud accent. The same seven overrides, as far from Editorial as the token system allows — the registry’s proof that a theme change is not just a recolour.
The admin’s theme card lists exactly which slots a theme overrides, so compare against that rather than this paragraph if the two ever disagree.