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.
The two files
Section titled “The two files”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.
The routes come from the kit
Section titled “The routes come from the kit”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.
The layouts
Section titled “The layouts”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.
Trying it
Section titled “Trying it”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:
GOPHENBERG_API_URL=http://localhost:8081 \GOPHENBERG_ASSET_ORIGIN=http://localhost:8081 \pnpm astro devWhen it looks right, installing a theme takes it to production.