Skip to content

Writing a theme

A theme is an Astro project using the @gophenberg/astro kit. You write two files: an Astro config naming the integration, and a theme definition naming your layouts. The kit does the rest, including the routes.

The best starting point is copying the reference theme from the Gophenberg repository. This page walks its shape.

astro.config.mjs turns the project into a theme:

import { gophenberg } from '@gophenberg/astro/config'
import { defineConfig } from 'astro/config'
export default defineConfig({
integrations: [gophenberg()],
})

src/theme.ts declares what your theme provides:

import { defineTheme } from '@gophenberg/astro'
import Archive from './layouts/Archive.astro'
import Base from './layouts/Base.astro'
import NotFound from './layouts/NotFound.astro'
import Post from './layouts/Post.astro'
import Term from './layouts/Term.astro'
export default defineTheme({
layouts: { Base, Post, Archive, Term, NotFound },
pagination: { perPage: 10 },
seo: { siteName: 'My Site' },
})

NotFound is optional, and a blocks map can join the object to override single block types, covered in rendering blocks.

pagination is optional too. Leave it out and listings carry the number of items the site chose in its settings. Name a perPage and the theme decides instead, whatever the site chose.

You write no pages for content. The integration injects the front page, one catch-all route serving every stored address, the 404 page, and the health route Gophenberg probes at startup. The catch-all asks Gophenberg what an address holds and renders your Post layout for an item, your Archive layout for a listing, or your Term layout for an item that lists what points at it, with the content already fetched.

Your own pages coexist with them: src/pages/about.astro serves /about as in any Astro site. To put posts on a page of your own, read the content API directly.

Base wraps every page. It receives seo and an optional title, and its one obligation is rendering GophenbergHead inside <head>, which emits the page title and the stylesheets that make block content look right:

---
import { GophenbergHead } from '@gophenberg/astro/components'
interface Props {
seo: { siteName: string }
title?: string
}
const { seo, title } = Astro.props
---
<html lang="en">
<head>
<GophenbergHead title={title ? `${title} | ${seo.siteName}` : seo.siteName} />
</head>
<body>
<header><a href="/">{seo.siteName}</a></header>
<main><slot /></main>
</body>
</html>

Post receives the post, your blocks overrides, and seo, and renders the content through the Blocks component:

---
import { Blocks } from '@gophenberg/astro/components'
import type { BlockComponentMap, Post } from '@gophenberg/astro'
import Base from './Base.astro'
interface Props {
post: Post
blocks?: BlockComponentMap
seo: { siteName: string }
}
const { post, blocks, seo } = Astro.props
---
<Base seo={seo} title={post.title}>
<article>
<h1>{post.title}</h1>
<Blocks html={post.content} components={blocks} post={post} />
</article>
</Base>

Archive receives posts (summaries without content), total, page, perPage, type (the content type being listed, carrying its labels, its route word, and the settings each of its fields holds), and seo. NotFound receives seo.

Term renders an item that lists what points at it, a category page for example. It receives the Archive props plus term, the item itself as a full Post, and blocks.

A post’s fields object carries its values, and relation values name the items they point at. The kit’s relatedFields helper picks the relations out, so linking a post to its categories is:

---
import { relatedFields } from '@gophenberg/astro'
const related = relatedFields(post)
---
{
related.map((field) => (
<ul>
{field.items.map((item) => (
<li>
<a href={`/${item.path}`}>{item.title}</a>
</li>
))}
</ul>
))
}

relatedFields returns relations only, and a choice value comes back as the stored value. It reads the fields standing at the top of a group. For a relation inside a Section or a Repeater row, read the row first and hand its value to relatedItems, which names the items one value points at:

---
import { heldRows, relatedItems } from '@gophenberg/astro'
const team = heldRows(post, 'team')
---
{
team.map((row) =>
relatedItems(row.filed)?.map((item) => <a href={`/${item.path}`}>{item.title}</a>),
)
}

The shapes are covered in the content API.

A Linked from field reads a relation the other way, and pointingFields picks those out. Its entries carry a type beside the id, title and path a relation entry holds, because items of several types can point through one relation:

---
import { pointingFields } from '@gophenberg/astro'
const pointing = pointingFields(post)
---

A Link field holds one address rather than a list, and linkFields picks those out, leaving out a Link that points nowhere:

---
import { linkFields } from '@gophenberg/astro'
const links = linkFields(post)
---
{
links.map((field) => (
<a href={field.link.url} target={field.link.new_tab ? '_blank' : undefined}>
{field.link.title === '' ? field.link.url : field.link.title}
</a>
))
}

Each helper reads one kind and never another, so a Linked from field never arrives through relatedFields, and a Link never arrives through either.

Media fields work the same way through mediaFields, which reads a Media field and a Gallery alike, so one loop renders either:

---
import { mediaFields, mediaUrl } from '@gophenberg/astro'
const pictured = mediaFields(post)
---
{
pictured.map((field) => (
<ul>
{field.items.map((item) => (
<li>
<img
src={mediaUrl(item.src)}
alt={item.alt_text}
width={item.width}
height={item.height}
loading="lazy"
/>
</li>
))}
</ul>
))
}

Always pass mediaUrl, which is what lets a theme running on its own address during development still load files from the instance. Giving width and height stops the page jumping as images arrive. item.sizes holds the other renditions when you want a srcset, and it can be empty, so check before reaching into it.

A Section groups fields under one key, and a Repeater holds rows of them. The kit’s heldSection helper reads the values a section holds, heldRows reads a repeater’s rows, and heldValue follows a path of keys and row numbers to one value.

---
import { heldRows, heldSection } from '@gophenberg/astro'
const author = heldSection(post, 'author')
const team = heldRows(post, 'team')
---
<p>{author?.name}</p>
<ul>
{team.map((row) => <li>{row.name} works as {row.role}</li>)}
</ul>

A section nobody filled in comes back as undefined, and heldRows answers an empty list when the repeater holds nothing.

A Flexible content field holds rows too, but each row picks one of the layouts the field offers and carries only that layout’s fields. heldLayouts reads those rows, naming the layout each one picked so the theme can draw it its own way.

---
import { heldLayouts } from '@gophenberg/astro'
const features = heldLayouts(post, 'features')
---
{features.map(({ layout, values }) => {
if (layout === 'hero') {
return <h2>{values.headline}</h2>
}
if (layout === 'quote') {
return <blockquote>{values.saying}</blockquote>
}
return null
})}

Name every layout you draw and return nothing for the rest, as above, since a layout added later arrives as a name the theme has never seen. heldLayouts answers an empty list when the field holds nothing. It keeps a row only when the row names exactly one layout and holds that layout’s values under it, and leaves out anything else. heldValue reaches inside a row the same way it reaches inside a repeater, so heldValue(post, ['features', 0, 'hero', 'headline']) reads the first row’s headline. The content API describes the shapes these values take.

astro dev runs your theme against a running Gophenberg. It needs two addresses, one for content and one for the block stylesheets, which the dev server does not serve itself:

Terminal window
GOPHENBERG_API_URL=http://localhost:8081 \
GOPHENBERG_ASSET_ORIGIN=http://localhost:8081 \
pnpm astro dev

When it looks right, installing a theme takes it to production.