# Theme development A theme controls how the public site **looks** and, since template slots landed, how it is **structured**. Themes come in two strengths (and ship in three ways — see below): | | **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](./README.md#extensibility-model). --- ## Three ways to ship a theme | | Declarative (`theme.json`) | External (`ASTROBAAS_THEMES`) | Bundled (`defineTheme`) | | --- | --- | --- | --- | | Install | Paste JSON into Admin → Themes, or `POST /api/themes/install`. No rebuild. | `npm i @someone/theme-x`, name it in `ASTROBAAS_THEMES`, restart. | Add `src/themes//`, one import in `src/themes/index.ts`, redeploy. | | Tokens | ✅ | ✅ | ✅ | | Stylesheet | ✅ | ✅ | ✅ | | Patterns | ✅ | ✅ | ✅ | | Replace slots (components) | ❌ | ✅ | ✅ | 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 the other two when you need to change the page *structure*, which needs real components and therefore a build. ### The external tier (`ASTROBAAS_THEMES`) A comma-separated list of module specifiers, each default-exporting a theme, an array of themes, or a **factory** `({ defineTheme, apiVersion }) => theme` — deliberately the same shape as `ASTROBAAS_PLUGINS`, and for the same reason: an external module cannot resolve `astrobaas/core` (inside this repo that is a tsconfig path alias, and in a deployed install there is no such package), so the factory hands it *this* host's `defineTheme`. **An external theme may carry components, and a declarative manifest may not.** That is not an inconsistency: a manifest is data an admin installs through the browser at runtime, while a module named in the environment is code the operator put on the server on purpose — the same line `ASTROBAAS_PLUGINS` already draws. A theme that will not load is logged with its specifier and its reason and the site renders with the stock look; boot continues. A module may not claim a bundled id, so a package exporting a theme called `default` cannot quietly replace the stock look. See [`src/themes/external.ts`](./src/themes/external.ts). ### A declarative theme ```json { "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)); }", "screenshot": "/uploads/sunset-preview.png", "patterns": [ { "name": "splash", "label": "Splash", "description": "Full-bleed hero.", "html": "

Headline

" } ] } ``` Five rules, all enforced at install rather than discovered on a live site: 1. **Colours must be hex.** Token values are written into `/theme.css` as `--primary-color: `, so a value carrying `;` or `}` would escape the declaration and become arbitrary CSS. A stored value may *select* CSS; it may never *be* CSS. 2. **`style` values must be token KEYS**, not CSS. `"radius": "lg"` — not `"14px"`. The key names a pre-authored block; the raw value is refused. 3. **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. 4. **`css` is filtered but NOT namespace-scoped.** Unlike a plugin, restyling the whole site is your job. `@import`, `` escapes and `javascript:` URLs are removed; anything over 100 KB is refused whole rather than truncated (a cut inside `@media (…) {` swallows every rule after it). 5. **`screenshot`, if present, must be a `data:` image URI (png, jpeg, webp or svg+xml) or a root-relative path**, and under 200 KB — it has to fit in a settings-sized value. Anything else is refused. 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// index.ts # defineTheme({...}) — required Header.astro # optional slot overrides PostCard.astro ``` ```ts // 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`](./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`, `localeOptions?` — language-switcher entries for THIS page. Only a record route can build them (switching language on an article must land on that article's translation, which has its own slug); left undefined on static routes like `/blog` or `/`, where one template serves every language at the same path. | | `Footer` | Site footer on every public page | `siteTitle`, `social?`, `locale`. `social` is `{ twitter?, github?, linkedin?, facebook?, instagram?, youtube? }` — six keys, each mapped from the matching `social_*` setting in `src/lib/site.ts`. Render the ones you want; `tests/theme-slots.test.mjs` enforces the contract **in both directions**, so a key here that `site.ts` does not populate fails the suite, and so does a theme reading a key that is not in the contract. | | `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? }`), `locale?` — every other chrome slot receives it, and without it a theme cannot translate the landmark label, which left a German page announcing its breadcrumb navigation with the English word. | | `TableOfContents` | The contents list for the article being read. A slot because *where* a ToC belongs is a design decision: the default puts it above the article, a magazine theme would put it in the sidebar, an editorial one might want none. The items are computed by the content pipeline, not here, so the anchors it links to and the ids in the body are one list rather than two guesses at it. | `items: TocItem[]` (`{ id, text, level, depth }` — each already carrying its anchor), `label?` (localized heading for the block), `class?`. `TableOfContentsProps` and `TocItem` are **not** re-exported from `astrobaas/core` yet; type an override with `ThemeSlotProps['TableOfContents']`, which is. | > **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 — so `style="color: var(--text-color)"` is dropped > silently by the browser. It looks right in `astro dev` (no CSP) and loses its > colour in production, which is the worst shape a bug can take. Use the shared > `ab-*` 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 > `