Template Reference
A Chinaski theme is a flat directory of Template Toolkit 2 (.tt) templates. The build process renders your templates into static HTML for every page and post, once per configured language. This page covers the theme files, the variables they receive, and the helpers and rules that come with them.
Where themes live
- The built-in default theme ships with the application and serves as the fallback.
- Custom themes live in
builder/themes/<your-theme-slug>/. When a theme with the active theme’s slug exists there, the builder uses it; otherwise it falls back to the built-in templates. - Which theme is active is chosen in Settings.
Required and optional template files
| File | Purpose | Required? |
|---|---|---|
page.tt | Default template for every published page | Yes |
post.tt | Template for every published blog post | Yes |
blog_index.tt | Paginated blog listing and category pages | Yes |
404.tt | Per-language 404 page | Yes |
_head.tt | <head> partial: CSS, meta tags, font imports | Convention |
_header.tt | Site header: logo, nav, tagline | Convention |
_footer.tt | Site footer: copyright, secondary nav | Convention |
_cookie_notice.tt | Optional GDPR banner | No |
_sidebar.tt | Optional sidebar partial | No |
search.tt | Optional search results page | No |
_password_gate.tt | Client-side password-gate UI for protected pages | Required if page.tt/post.tt support password_hash |
<custom>.tt | Any page that selects it via the template setting | No |
Files starting with _ are partials and are never rendered as standalone pages.
Template variables
Templates receive a flat set of variables: there are no page. or site. objects. Use site_name, title, lang, and so on directly.
page.tt and 404.tt
| Variable | Type | Notes |
|---|---|---|
title | string | Page title |
body | HTML string | Pre-rendered: do not escape with | html |
slug | string | URL slug |
lang / language | string | 2-letter code, e.g. en |
meta_title | string? | Overrides <title> |
meta_description | string? | SEO description |
og_title | string? | OpenGraph title |
og_description | string? | OG description |
og_image_url | string? | OG image |
canonical_url | string | Auto-generated |
featured_image_url | string? | Hero image |
featured_image_alt | string? | Alt text |
updated_at | string | SQLite datetime |
site_name | string | From settings |
site_tagline | string | From settings |
footer_copyright | string | From settings |
year | integer | Build year |
url_base | string | URL prefix: may be empty; prepend to ALL internal links |
cookie_notice_active | 0/1 | Whether to render _cookie_notice.tt |
noindex | 0/1 | Emit <meta name="robots" content="noindex"> |
nav_items | array | {label, url, slot, sort_order}: slot is header or footer |
hreflang_links | array | {hreflang, href} for translations |
breadcrumbs | array | {name, url, is_current} |
json_ld | HTML string | Pre-serialised JSON-LD: emit raw |
ga4_id | string? | Google Analytics ID |
gtm_id | string? | GTM container ID |
twitter_site | string? | Twitter handle with @ |
password_hash | string? | If set, you MUST wrap the content in _password_gate.tt (see below) |
post.tt: everything from page.tt, plus
| Variable | Type | Notes |
|---|---|---|
excerpt | string | Author-supplied excerpt; may be empty |
category | string? | Single category name |
tags | string | Comma-separated; split with tags.split(',') |
author | string | Display name |
is_sticky | 0/1 | Pinned to top of blog index |
published_at | string? | SQLite datetime; first publish moment |
date | string | YYYY-MM-DD slice of published_at |
feed_url | string | Absolute RSS feed URL |
og_type | string | Always 'article' |
blog_index.tt
| Variable | Type | Notes |
|---|---|---|
posts | array | Sorted post hashes with keys p.title, p.slug, p.date, p.body, p.category |
pagination | hash? | {current_page, total_pages, prev_url, next_url, abs_prev_url, abs_next_url}: undef when only one page |
title | string | Localised “Blog” or category name |
category_name | string? | Set when filtering by category |
search.tt
| Variable | Type | Notes |
|---|---|---|
query | string | Search term |
results | array | {title, url, excerpt} |
result_count | integer | Total matches |
Blocks
Authors place blocks by writing {{block:my-cta}} in a page or post body. At build time the builder finds the shortcode, looks up the published block with that name and language, renders it with its template, and substitutes the rendered HTML into the body.
Block templates are shared across themes and live outside your theme directory; your theme is responsible for the CSS that styles their output.
CSS classes your theme must style
Each block type emits a predictable wrapper class and inner classes. Your _head.tt must style these:
| Type | Wrapper | Inner classes |
|---|---|---|
cta | .cta-block | .cta-inner .cta-heading .cta-subheading .cta-button |
image | .image-block | .image-caption |
quote | .quote-block | .quote-text .quote-attribution |
hero | .hero-block | .hero-media .hero-inner .hero-heading .hero-subheading .hero-button |
testimonial | .testimonial-block | .testimonial-label .testimonial-body .testimonial-avatar .testimonial-text .testimonial-attribution |
stat | .stat-block | .stat-number .stat-label |
promo | .promo-block | .promo-media .promo-content .promo-heading .promo-body .promo-button |
embed | .embed-block | .embed-caption .embed-content |
video | .video-block | .video-heading .video-wrapper .video-caption |
form | .form-block | .form-heading .form-subheading .block-form .block-form__field |
newsletter | .newsletter-block | .newsletter-inner .newsletter-heading .newsletter-subheading .newsletter-form .newsletter-form__row |
accordion | .accordion-block | .accordion-heading .accordion .accordion__item .accordion__summary .accordion__body |
table | .table-block | .table-heading .table-wrapper .block-table |
columns | .columns-block | .columns-heading .columns .columns--2 .columns--3 .columns--4 .column .column__image .column__heading .column__body |
gallery | .gallery-block | .gallery-heading .gallery .gallery__item |
Minimum required styling for the utility classes:
.video-wrapper:position: relative; padding-bottom: 56.25%;with the iframeposition: absolute; inset: 0; width: 100%; height: 100%;(16:9 responsive video).columns--2/.columns--3/.columns--4:display: gridwith appropriategrid-template-columns.table-wrapper:overflow-x: autoso tables scroll on mobile.gallery: responsive grid with gap.accordion__item/.accordion__summary: basicdetails/summaryreset
Multi-language pages
- Every page is rendered once per configured language.
- Internal links must include the language prefix: use
[% url_base %]/[% lang %]/contact/or similar. The only exception isnav_items[].url, which already contains the full correct URL; use it as-is. - The
<html>element must carry the language:<html lang="[% lang | html %]">.
Security rules
The following directives are forbidden in theme and block content and are rejected:
[% PERL %]/[% RAWPERL %][% INSERT '/absolute/path' %][% USE ... %]of modules like File/IO/Socket/Net/LWP/WWW/HTTP/System/Cmd/Process/Pipe/Expect
Password-protected pages
Password protection is a reader-facing gate: the page content is still present in the published HTML, and _password_gate.tt hides it behind a JavaScript check until the visitor enters the right password. page.tt and post.tt must implement it like this:
[% IF password_hash %]
[% WRAPPER '_password_gate.tt' %][% PROCESS page_content %][% END %]
[% ELSE %]
[% PROCESS page_content %]
[% END %] (Wrap whatever block holds your main content: the block’s exact name doesn’t matter, only that it is defined and processed inside the wrapper.) If your template omits the password_hash branch entirely, an administrator can still set a password on a page using your theme, and the page will render publicly with no warning that the gate is missing.
Cheat sheet
[% variable %]: raw output[% variable | html %]: HTML-escaped (use for ALL user-controlled text)[% variable | uri %]: URI-encode (for URL path segments)[% IF / ELSE / END %],[% UNLESS ... %]: conditionals[% FOREACH item IN items %]: loops;loop.index/loop.countavailable[% INCLUDE '_partial.tt' %]: include a partial, sharing the current variables; addx = 42to pass or shadow values[% PROCESS '_macros.tt' %]: like INCLUDE but without introducing a new scope[%- ... -%]: trim surrounding whitespace
Key gotchas
- Flat stash: no nested objects.
site.title,page.title,post.titleare all wrong; usesite_nameandtitle. - Nav item fields are
label,url,slot, notname,href,active. - Dates: never call date formatting on
published_at; use the pre-computeddatevariable, or take the first 10 characters ofpublished_at. - Body is pre-rendered HTML: output
[% body %]without escaping. - Partials are prefixed with
_. - All internal links need
url_baseandlang.