Theme Building
Building a full theme for notACMS — template layers, Twig API, context contracts, and portability rules.
Template Layer Hierarchy
notACMS resolves templates from three layers, applied in priority order:
local/templates/— site-specific overrides (highest priority)- Theme templates — registered via
twig.pathsinlocal/config/packages/twig.yaml - Core bare templates in
templates/
Any file in local/templates/ overrides the equivalent in the registered theme or core. The bare theme ships a minimal, complete set of templates that renders all content types without external dependencies. The demo theme (this site) adds styling and components on top.
Template ↔ Context Contracts
Every template receives the Twig globals plus page-specific context. locale is always present.
Page Templates
| Route | Template | Key context |
|---|---|---|
home_{locale} |
page/home.html.twig |
content (?ContentItem), recentPosts (ContentItem[]), card_layouts |
static_page_{locale} |
frontmatter template: |
content (ContentItem) |
contact_{locale} |
page/contact.html.twig |
content, form, contact_form_available (bool) |
projects_{locale} |
page/projects.html.twig |
content, featured_projects, total_projects |
search_{locale} |
search/index.html.twig |
locale only |
| error pages | page/error.html.twig |
status_code, status_text, locale, content (null) |
Blog Templates
| Route | Template | Key context |
|---|---|---|
| blog list + paginated | blog/list.html.twig |
posts, current_page, total_pages, total_posts, filter_type, filter_value, index_content, card_layouts |
| single post | blog/post.html.twig |
content, related_posts, prev_post, next_post, series_posts, series_current_part, card_layouts |
| scheduled post | page/coming-soon.html.twig |
post, card_layouts |
filter_type values: 'category', 'tag', 'archive'. card_layouts cycles through ['layout-top', 'layout-right', 'layout-text', 'layout-left'].
Feed & Email Templates
| Template | Context |
|---|---|
feed/rss.xml.twig |
posts (ContentItem[]), locale |
feed/sitemap.xml.twig |
items_by_locale (array<locale, ContentItem[]>) |
feed/llms.txt.twig |
posts_by_locale, pages |
feed/robots.txt.twig |
globals only |
email/contact.html.twig |
contact (array: name, email, website, subject, message), locale |
Twig Globals
Available in every template:
| Global | Type | Source |
|---|---|---|
site_name, site_base_url, site_description |
string |
_site.yaml |
site_social |
array |
_site.yaml social: (shape is site-defined) |
site_author |
array |
_site.yaml author: — always guard with |default() |
site_locales |
array<locale, array> |
per-locale label, og_locale, date_format, … |
site_locales_list |
string[] |
locale codes, first = default |
site_default_locale |
string |
first key of locales: |
image_variant_widths |
int[] |
_site.yaml (default [640, 960]) |
new_post_days, coming_soon_reveal_days, meta_description_length |
int |
_site.yaml |
translation_map |
array<directoryKey, array<locale, url>> |
published translations only; keys are full relative content paths |
cf_analytics_token, turnstile_site_key, notacms_project_url |
string |
twig.yaml env vars |
Twig Functions & Filters
| Function / filter | Signature | Returns |
|---|---|---|
content_item(key, locale) |
(string, string): ?ContentItem |
Item by directory key — nullable, always guard |
content_url(key, locale) |
(string, string): string |
Item URL; '/' when not found |
breadcrumbs(content, locale, options) |
(?ContentItem, string, array): Breadcrumb[] |
.label and .url (null = current page) |
lang_switch_urls(other_locales) |
(string[]): array<locale, url> |
Language-switcher URLs |
og_image_url(content, site_base_url) |
(?ContentItem, string): string |
Featured image URL or default OG image |
post_badge(post, new_post_days) |
(ContentItem, int): ?string |
'new', 'updated', or null |
sidebar_data(locale) |
(string): SidebarData |
.recentPosts, .categories, .tags, .archiveMonths |
structured_data() |
(): StructuredDataBuilderInterface |
Schema.org builder — webSite(), blogPosting(), breadcrumbList(), etc. |
json_ld(data) |
(array): string |
<script type="application/ld+json"> block |
srcset_media (filter) |
(string): string |
Injects srcset/sizes into <img> tags — standard body pipeline: content.htmlContent|srcset_media|raw |
ContentItem API
Methods available on any ContentItem in templates:
$item->title() // string
$item->description() // string
$item->slug() // string — last segment of the slug frontmatter
$item->url() // string — full path incl. locale prefix
$item->date() // ?DateTimeImmutable — guard before |date()
$item->updatedDate() // ?DateTimeImmutable
$item->tags() // string[]
$item->category() // ?string
$item->image() // ?string
$item->imageAlt() // string (defaults to title)
$item->excerpt() // string
$item->readingTime() // int
$item->wordCount() // int
$item->menuLabel() // string
$item->menuWeight() // int
$item->series() // ?string
$item->seriesOrder() // ?int
$item->directoryKey() // ?string — full relative content path
$item->isDraft() // bool
$item->isScheduled() // bool
$item->isPinned() // bool
$item->isFeatured() // bool
$item->hasToc() // bool
$item->htmlContent // string (public property) — rendered HTML
$item->locale // string (public property)
Required Translation Keys
Every theme catalog must define these keys in every locale:
| Key | Used by |
|---|---|
blog.title |
blog_filter_title() fallback when no blog index page exists |
contact.form.error, contact.form.success |
contact API JSON responses |
contact.form.name, .email, .website, .subject, .message |
form field labels |
contact.form.unavailable |
shown when contact form is not configured |
Keys used by templates your theme does not override must also exist in your catalogs — the translator falls back to core translations/ only for keys you don't define yourself.
Theme Portability Rules
- Iterate
site_locales_list— never hardcode locale codes or assume exactly two locales. - Guard nullable lookups —
content_item()returns null;site_author.*keys are site-defined (site_author.name|default(site_name));content.date()can be null. - Don't compare
directoryKey()to literals unless you control the content layout — it returns the full relative path. - Every
|transkey in your templates must exist in every locale catalog you ship — missing keys render as raw key strings. - Don't pattern-match URLs for section detection when content metadata can answer the question.
- No
|rawon translation-catalog output — use|sanitize_html('app.trans_inline')when a theme genuinely needs inline markup in translated copy.
Assets
Theme assets under local/assets/ are referenced as asset('local/<file>'). Core assets use asset('<file>'). Content images are served at /media/{basename}/{filename}. The responsive image pipeline is WebP-only: variant URLs are <base>-<width>w.webp for each image_variant_widths entry.
The complete API contract — including all context fields, value object shapes, and edge-case notes — is maintained in docs/THEME_BUILDING.md. Breaking changes are documented in UPGRADE-X.Y.md.