Skip to content
Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

How the public site's look and markup are controlled in AstroBaaS: the three ways to ship a theme, design tokens, template slots, child themes, the customizer, and the Content Security Policy rules theme authors must follow. Written for developers and designers building or adapting a theme; operators who only want to switch or recolour a theme can skip to The customizer.

The extensibility model

AstroBaaS draws one line through all extensions: data installs at runtime, code is compiled in. Astro compiles the server at build time, so anything that has to execute or render as a component must be present at build time. What can be expressed as data (colours, type, a stylesheet, section patterns) can be installed on a running site.

For themes this gives three delivery routes:

Declarative (theme.json) External (ASTROBAAS_THEMES) Bundled (defineTheme)
How it is installed Paste JSON into Admin → Themes, or POST /api/themes/install. No rebuild. npm install the package, name it in ASTROBAAS_THEMES, restart. Add src/themes/<id>/, add one import to src/themes/index.ts, rebuild and redeploy.
Design tokens Yes Yes Yes
Stylesheet (css) Yes Yes Yes
Section patterns Yes Yes Yes
Replace template slots (components) No Yes Yes
Runs code Never Yes, operator-installed Yes, compiled in

Start declarative. It covers colour, typography, shape, a stylesheet and ready-made page layouts, which is most of what a theme is. Move to an external or bundled theme only when you need to change page structure, which needs real .astro components.

There is no way to upload template code through the browser. Markup is code, and code arrives either compiled into the build or placed on the server by the operator. See the extensibility model in the README.

Themes present content; they do not:

  • add routes or pages (use a plugin or a custom content type instead),
  • change the admin,
  • ship server logic (that is what plugins are for).

Bundled themes

Three themes ship in src/themes/:

Id What it is Slots overridden
default The stock site. Overrides nothing; the reference for what each default slot renders. none
editorial A serif magazine look. The reference for a template theme. Header, Footer, Home, PostCard, PostArticle, PageArticle, Breadcrumbs
marquee A street-poster look: display caps, heavy rules, one loud accent. the same seven

Both editorial and marquee inherit Sidebar and TableOfContents from the defaults. The theme card in Admin → Themes lists exactly which slots a theme overrides; trust that over this table if they ever differ.

Declarative themes (runtime install)

A declarative theme is a JSON manifest. It is validated, stored in the database and interpreted by existing code. Nothing is executed.

{
  "id": "sunset",
  "name": "Sunset",
  "version": "1.0.0",
  "author": "You",
  "astrobaasApi": "^1.0.0",
  "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)); }",
  "screenshot": "/uploads/sunset-preview.png",
  "patterns": [
    {
      "name": "splash",
      "label": "Splash",
      "description": "Full-bleed hero.",
      "html": "<div class=\"ab-hero ab-align-center\"><h2>Headline</h2></div>"
    }
  ]
}

Manifest fields

Field Required Notes
id yes Kebab-case. May not reuse a bundled theme id.
name yes Display name.
version yes Semver.
description, author no Shown in the admin.
homepage no https only.
astrobaasApi no Semver range of the theme manifest API. The host implements 1.0.0.
tokens no Partial design tokens: colors, typography, style, colorScheme. Anything omitted falls back to the stock defaults.
css no Stylesheet appended to /theme.css. Up to 100 KB.
patterns no Up to 24 section patterns, each up to 8,000 characters of HTML.
screenshot no Preview for the theme card.

Validation rules

All of these are enforced at install time, not discovered on a live site:

  1. Colours must be hex (#1a2b3c). Token values are written into /theme.css as custom properties, so a value carrying ; or } could otherwise escape into arbitrary CSS.
  2. style values must be token keys, not CSS. "radius": "lg" is accepted; "radius": "14px" is refused. Unknown scale names are refused too.
  3. Every pattern must survive the content sanitizer byte-for-byte. A pattern that does not is refused, and the error response shows your markup next to what the sanitizer returned.
  4. css is filtered but not namespace-scoped. @import, </style> escapes and javascript: URLs are removed. Anything over 100 KB is refused whole rather than truncated.
  5. screenshot must be a data: image URI (png, jpeg, webp or svg+xml) or a root-relative path, under 200 KB.
  6. tokens.customCSS is refused. Custom CSS belongs to the operator; a theme uses css.

Unlike a plugin, a theme's CSS may restyle the whole site, because that is its job. It cannot run code, read data or make requests, but it does control what visitors see. Install themes only from sources you would accept a stylesheet from.

Installing, upgrading, uninstalling

In the admin, open Admin → Themes, paste the manifest into the install box and install. Over HTTP (admin session required):

POST /api/themes/install
Content-Type: application/json

{ "manifest": { "id": "sunset", "name": "Sunset", "version": "1.0.0" } }
DELETE /api/themes/install
Content-Type: application/json

{ "id": "sunset" }

Behaviour to know:

  • Installing does not activate. Activate it from the theme list, or with POST /api/themes/activate and { "id": "sunset" }.
  • Upgrading keeps the operator's customizations. The manifest's tokens are the theme's defaults and only seed settings on first install.
  • Uninstalling an active theme is refused. Activate another theme first.
  • Bundled themes cannot be uninstalled through the API; remove the import and rebuild instead.

External themes (ASTROBAAS_THEMES)

ASTROBAAS_THEMES is a comma-separated list of module specifiers, loaded at boot. It mirrors ASTROBAAS_PLUGINS (see Plugins). Each module default-exports one of:

  • a theme object,
  • an array of themes,
  • a factory ({ defineTheme, apiVersion }) => theme | theme[].

Prefer the factory. An external module cannot resolve astrobaas/core in a deployed install, so the host passes in its own defineTheme.

// @acme/theme-harbour/dist/index.mjs
export default function create({ defineTheme, apiVersion }) {
  return defineTheme({
    id: 'harbour',
    name: 'Harbour',
    description: 'Navy and brass.',
    version: '1.0.0',
    author: 'Acme',
    requiresCore: '^1.0.0',   // refused at load if the host API does not match
    settings: {
      colors: { primary: '#0b2545', secondary: '#13315c', accent: '#b08d57',
                background: '#ffffff', text: '#0b2545' },
      typography: { headingFont: 'Lora', bodyFont: 'Inter', fontSize: '17px' },
    },
    css: '.ab-hero { border-bottom: 4px solid var(--accent-color); }',
  });
}
npm install @acme/theme-harbour
ASTROBAAS_THEMES=@acme/theme-harbour npm start

apiVersion is the host's API version (currently 1.0.0). A theme that declares an incompatible requiresCore range is refused at load with a message naming both versions. Two packages claiming the same id are refused too.

An external theme may also carry components, while a declarative manifest may not. That is intentional: a manifest is data an admin installs through the browser, whereas a module named in the environment is code the operator deliberately put on the server.

A theme that fails to load is logged with its specifier and reason, the site renders with the stock look, and boot continues. A module may not claim a bundled id, so a package exporting a theme called default cannot replace the stock look. Source: src/themes/external.ts.

Bundled theme anatomy

src/themes/<your-id>/
  index.ts          # defineTheme({...}), required
  Header.astro      # optional slot overrides
  PostCard.astro

defineTheme is an identity helper exported from astrobaas/core that gives you type checking. Register the theme in src/themes/index.ts with one import and one entry in BUNDLED_THEMES, then rebuild. It appears in Admin → Themes, ready to activate.

defineTheme accepts:

Field Notes
id Stable, kebab-case, unique. Links the module to its database row.
name, description, version, author Shown in the admin.
settings Default design tokens. Complete for a root theme; partial or omitted for a child theme.
components Slot overrides. Omit for a tokens-only theme.
extends Parent theme id, for a child theme.
css Stylesheet appended to /theme.css after the token declarations and before the operator's custom CSS. 100 KB limit.
patterns Section patterns offered in the editor.
screenshot data: image URI or root-relative path.

Template slots

A theme may replace any subset of a fixed set of components. Every slot you do not declare falls back to the built-in component, so a theme keeps working when new slots are added.

Slot Renders Main props
Header Site header on every public page siteTitle, locale, localeOptions? (language-switcher entries, only set on record routes)
Footer Site footer on every public page siteTitle, social?, locale
PostCard One post in a listing post (with author, category, image, date, read time resolved)
PostArticle The 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 set as home siteTitle, siteTagline, posts, locale
PageArticle A CMS Page at /{slug}, and at / when designated home post, contentHtml, isHome
Breadcrumbs The trail above content items ({ name, href? }[]), locale?
TableOfContents Contents list for the article items ({ id, text, level, depth }[]), label?, class?

Prop types live in src/core/theme-slots.ts and are exported from astrobaas/core (HeaderProps, PostCardProps, TableOfContentsProps, and so on). ThemeSlotProps['<Slot>'] always resolves to a slot's props.

Slot rules:

  • Do not fetch in a slot. The route has already resolved the data; extra queries multiply per post.
  • contentHtml is already sanitized. Render it with set:html and do not re-sanitize or unescape it. It is the only value you should pass to set:html.
  • Breadcrumbs: render nothing below two items, and mark the last item aria-current="page". The same array feeds the page's BreadcrumbList structured data.
  • Footer social has six keys (twitter, github, linkedin, facebook, instagram, youtube), mapped from the social_* settings. tests/theme-slots.test.mjs fails if a theme reads a key outside that contract.

Design tokens

A theme's settings are its defaults. Operators override them in the customizer and their values win, so read values from CSS custom properties instead of hard-coding them:

.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 CSP applies unchanged.

Group Custom properties
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
Typography --heading-font, --body-font, --font-size-base, --letter-spacing, --line-height, --heading-line-height, --heading-weight, --h1, --h2, --h3
Shape and space --radius-sm, --radius-md, --radius-lg, --radius-pill, --space-1 to --space-8, --section-y, --shadow-sm, --shadow-md, --shadow-lg
Layout --container, --measure, --btn-bg, --btn-fg, --btn-border, --btn-radius, --header-align, --header-direction, --header-pad, --header-border

--on-primary is derived from the primary colour's luminance when not set, so text on a brand-coloured button stays readable. Chosen font families are fetched as webfonts by the layout; do not assume they are installed locally.

Enum scales

Shape, spacing and layout values are keys, not CSS. The key selects a pre-authored block of declarations in src/lib/theme-tokens.ts:

Scale Keys Default
radius none, sm, md, lg, full md
density compact, normal, roomy normal
shadow none, soft, strong soft
containerWidth narrow, normal, wide, full normal
typeScale compact, normal, spacious normal
headingWeight normal, medium, semibold, bold bold
buttonStyle solid, outline, soft, pill solid
headerStyle minimal, centered, split, masthead split

An unrecognised key falls back to the scale's default. Every key in a scale emits the same token names, so you can rely on a token existing whichever key the operator chose.

There is no --ab-* token namespace. The ab- prefix belongs to section classes (.ab-hero), never to custom properties; var(--ab-radius-md) silently resolves to nothing.

Dark mode

colorScheme is 'light', 'dark' or '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 is stored in a cookie that the server reads, stamping data-theme on <html> before the page is sent: no flash of the wrong scheme and no inline script.

A bundled or external theme may set settings.darkColors (primary, secondary, accent, background, surface, text, muted, border). Anything left out is derived from the light palette.

Sections and patterns

Editors build layouts from sections (hero, columns, card, CTA, note, media with text, gallery, spacer, table, video link). The editor inserts them as allow-listed classes on ordinary tags, for example <div class="ab-hero ab-align-center">. There is no block tree: post content remains one sanitized HTML string, so sections arrive inside contentHtml with nothing special to render.

The default section styles in src/styles/sections.css are written entirely in tokens, so changing a preset restyles every section. A theme can restyle sections from its css:

.ab-hero { padding-block: 6rem; text-align: start; }
.ab-columns { gap: var(--space-6); }

Two constraints:

  • Never use a Tailwind utility in section CSS or stored HTML. Tailwind generates classes by scanning source files, and database content is not scanned.
  • Scope parts to their parent (.ab-columns > .ab-col, not .ab-col), so an orphaned part degrades to readable content.

A theme can restyle a section but not restructure it. The vocabulary is defined in src/core/sections.ts.

Patterns are 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>',
}]

For bundled and external themes, a pattern that does not survive the sanitizer byte-for-byte is dropped with the reason shown in Admin → Tools. Declarative themes are refused at install instead. Pattern names are namespaced as <themeId>--<name>, so a theme cannot shadow a built-in pattern.

Child themes (extends)

A child theme declares a parent and overrides only what it needs. Everything else (components, tokens, CSS, patterns) comes from the parent, so later fixes to the parent still reach the child.

import { defineTheme } from 'astrobaas/core';
import Footer from './Footer.astro';

export default defineTheme({
  id: 'marquee-acme',
  name: 'Marquee (Acme)',
  description: 'Marquee in Acme blue with our own footer.',
  version: '1.0.0',
  author: 'Acme',
  extends: 'marquee',
  settings: { colors: { primary: '#005b8f' } }, // partial, or omit entirely
  components: { Footer },                       // Marquee's other slots are kept
});

A root theme must supply a complete settings palette; a child's settings is a deep partial and may be omitted. The TypeScript type enforces that difference.

Merge rules, from src/lib/theme-inherit.ts:

What Rule
Components Child wins per slot.
Tokens Child wins per leaf. Setting colors.primary keeps every other colour.
CSS Parent first, then child, so child rules win by cascade order.
Patterns Parent's, then child's; the child wins on a duplicate name.
Blank values '', null and undefined mean "inherit". false and 0 are real values.

A missing parent or a cycle resolves to the child plus whatever ancestors were reached, with a log message. The admin marks a child whose parent is not installed.

Presets

src/themes/presets.ts holds complete named bundles (colours, type, shape, density, dark palette) shown as one-click options in the customizer: clean, editorial, optical, boutique, midnight, bold, soft and terminal. Applying a preset fills the controls; saving is still an explicit step. Presets post the same payload as the manual controls, so there is one write path and one validator.

The customizer

Admin → Themes has three parts: an install box for manifests, the list of installed themes (bundled, external and declarative) with activate and uninstall buttons, and a customizer for the active theme.

The customizer covers:

  • colours, including the semantic colours,
  • typography (heading font, body font, base size, type scale, heading weight),
  • shape and layout scales (radius, density, shadow, container width, button style, header style),
  • colour scheme: light, dark or auto,
  • presets,
  • custom CSS, appended last to /theme.css so it overrides the theme.

Customizing, activating and installing themes all require an admin account. The operator's values always override the theme's defaults, and they survive theme upgrades.

CSP rules for theme authors

Production pages are served with a hash-based Content Security Policy with no 'unsafe-inline' and no 'unsafe-hashes'. The build hashes the scripts and styles it bundles, and the browser refuses everything else. Pages often look correct in astro dev (which has no CSP) and lose styling in production, so follow these rules from the start:

Do Do not
Use classes, including the shared utilities ab-ink, ab-muted, ab-accent, ab-on-accent, ab-surface, ab-bg-accent, ab-border-ink, ab-heading-font. Use style="..." attributes. Hashes never cover style attributes, so the browser drops them silently.
Use a scoped <style> block inside your .astro component. Astro hashes it. Emit an inline <style> or <script> at runtime.
Put global rules in the theme's css, served from /theme.css. Rely on @import in theme CSS; it is stripped.
Bundle scripts through Vite (import in a component script). Load scripts from a CDN without configuring the CSP.

To load assets from other origins, opt them in per directive through environment variables (see Configuration and Security):

CSP_SCRIPT_SRC=https://cdn.jsdelivr.net
CSP_STYLE_SRC=https://cdn.jsdelivr.net
CSP_FONT_SRC=https://fonts.gstatic.com
CSP_IMG_SRC=https://cdn.example.com
CSP_CONNECT_SRC=https://api.example.com
CSP_ALLOW_WASM=1        # allow WebAssembly (e.g. 3D decoders)

These are read when the site is built, so rebuild after changing them. While tuning, the browser console names each blocked request and the directive that blocked it. worker-src 'self' blob: is on by default. Bundling a library through npm and Vite needs no CSP change at all, which is the simplest option.

Animation, 3D and effects

Visual effects (parallax, scroll reveals, WebGL) belong in the frontend layer, not in post content: the content sanitizer never lets <canvas> or <script> into stored HTML. The pattern is:

  1. read content server-side in an .astro page through astrobaas/core or the REST API,
  2. render the structure as plain HTML,
  3. add effects with CSS and small bundled scripts, or with an Astro island (@astrojs/react is already installed) hydrated with client:visible,
  4. pass CMS data such as the active theme colour into the effect as props.
---
import { LocalDB } from 'astrobaas/core';
import Scene from '../components/Scene'; // a React Three Fiber island
const theme = await LocalDB.getActiveTheme();
---
<Scene client:visible color={theme?.settings.colors.primary} />

Always honour prefers-reduced-motion, prefer client:visible over client:load, and cap the device pixel ratio for WebGL. A working reference is the /showcase page (src/pages/showcase.astro). There is no visual page builder for effects; they are composed in code. Details: FRONTEND_EFFECTS.md.

Worked example: a minimal template theme

Scaffold it with the CLI, which refuses to overwrite existing files:

npx astrobaas theme new harbour

This writes src/themes/harbour/index.ts and src/themes/harbour/Header.astro. A trimmed version:

// src/themes/harbour/index.ts
import { defineTheme } from 'astrobaas/core';
import Header from './Header.astro';

export default defineTheme({
  id: 'harbour',
  name: 'Harbour',
  description: 'Navy and brass, centred header.',
  version: '1.0.0',
  author: 'You',
  settings: {
    colors: { primary: '#0b2545', secondary: '#13315c', accent: '#b08d57',
              background: '#ffffff', text: '#0b2545' },
    typography: { headingFont: 'Lora', bodyFont: 'Inter', fontSize: '17px' },
    style: { headerStyle: 'centered', radius: 'sm' },
    colorScheme: 'auto',
  },
  css: `.harbour-tagline { color: var(--muted-color); }`,
  components: { Header },
});
---
// src/themes/harbour/Header.astro
import type { HeaderProps } from 'astrobaas/core';
const { siteTitle = 'AstroBaaS' } = Astro.props as HeaderProps;
---
<header class="harbour-header">
  <a href="/" class="ab-heading-font ab-ink">{siteTitle}</a>
  <nav>
    <a href="/blog">Blog</a>
    <a href="/about">About</a>
  </nav>
</header>

<style>
  .harbour-header {
    display: flex;
    flex-direction: var(--header-direction);
    align-items: center;
    padding-block: var(--header-pad);
    border-bottom: var(--header-border) solid var(--border-color);
  }
  .harbour-header a { color: var(--primary-color); }
</style>

Register it:

// src/themes/index.ts
import harbourTheme from './harbour';
export const BUNDLED_THEMES: ThemeDefinition[] = [defaultTheme, editorialTheme, marqueeTheme, harbourTheme];

Add to the existing array; do not replace it. Then rebuild, open Admin → Themes, and activate Harbour.

Testing a theme

npm run build     # astro check type-checks your slots against the prop contracts, then builds
npm run dev       # activate the theme in Admin → Themes and click through the site
npm test          # unit tests plus the smoke suite (which starts its own dev server)

Theme-specific unit tests you can run on their own:

Command Checks
node tests/theme-slots.test.mjs Slot contracts, including the footer social keys.
npm run test:tokens Every scale key emits the same tokens, and every token has a consumer.
npm run test:theme-manifest Declarative manifest validation.
node tests/theme-distribution.test.mjs External theme loading and child-theme merge rules.

For a declarative theme, the install response is the test: it lists every validation error and, for a rejected pattern, shows the sanitizer's output next to yours. Check the theme card afterwards to confirm which slots are overridden, then view the site with the production CSP (npm run build and run the built server) before shipping.

Headless frontends as themes

A theme is not the only way to control how a site looks. You can build a separate frontend, for example a static Astro site, a Next.js app or a mobile app, that reads content from AstroBaaS over its REST API and renders it however you like. AstroBaaS then serves as the backend and admin, and your frontend owns all of the markup, routing and styling, with none of the slot or CSP constraints above applying to your separate deployment.

Choose this when you need routes or page types a theme cannot add, want to host the frontend elsewhere, or already have a design system in another framework. Content types you mark visibility: 'public' are readable without credentials; anything else needs an API key. See Headless and REST API and SDK, CLI and MCP.

See also

Clone this wiki locally