Your First Theme
Four files, one directory and one button: a theme the installation will actually serve.
Overview
A theme is not registered anywhere. Create a directory under templates/website, put a manifest in it, and the panel finds it.
Two gates decide whether the operator can switch to it: the manifest, and a default layout plus a home view.
Prerequisites
- Write access to
templates/websiteandtemp, where compiled templates land. - An installation you may switch themes on: activation replaces its public site.
- The Theme Engine read once, for the manifest-versus-values split.
developerturned on incoremio/configuration/debug.php, or a template error comes back as an empty page.
Structure
What the Gates Actually Require
The theme screen gates nothing: one card per directory, manifest or not. Everything is decided on Activate, and the first failing check is the message.
| Requirement | Checked when | If it is not met |
|---|---|---|
theme.php exists | Activating, and by the card | The card appears with no engine and an Unknown author; activation refuses it |
status is not 'development' | Activating, first | Refused with the development message; preview still works |
layouts/default.* | Activating, after the status check | Refused with the incomplete package message |
views/home.* | Activating, after the status check | Same: the two files are checked as a pair |
locale/{lang}.php | Never | Optional; without it labels print as raw keys |
The * is the extension your engine chose. The examples below use Smarty.
Step by Step
1. Create the Directory
- Create
templates/website/Acme/. The folder name is the theme's identity. - Create
layouts/,views/,locale/andassets/css/in it.
Reload the theme screen. The card shows an empty folder: placeholder cover, folder name, Unknown author.
2. Write the Manifest
- Create
templates/website/Acme/theme.phpreturning the array below. - Set
enginedeliberately: it decides every view's extension. Leaving it out means plain PHP. - Leave
update-urlempty while you build: the theme is then never checked for updates.
Reload the screen. The card now carries the name, version, author and engine. Activate is refused: status is 'development', checked first.
3. Add the Page Shell
- Create
layouts/default.tpl: the document, and the blocks a view fills. - Declare five blocks:
title,head,content,scripts,body_end. The shipped themes use these names. - Use
{asset}for every file and{lang}for every string.
Half the gate is satisfied; activation stays refused until the second file.
4. Add the Home View
- Create
views/home.tpl, extending the layout and fillingcontent. - Print one real value:
{$company_name}is on every client page.
Both halves of the integrity gate are in place; only status is left.
5. Add the Theme's Own Wording
- Create
locale/en.php: a flat map of key to text. - Put
nameanddescriptionin it; the card reads these before the manifest. - Add a key for every string the home view prints, referenced with
{lang key='...'}.
The card now shows your name and description.
6. Activate It
- Change
statusto'ready'intheme.php. While it says'development'the button is refused. - Press Activate on the Acme card in
{admin}/settings/theme?group=theme. - Open the site root in another tab.
The home page is your markup, stored as a single key in coremio/configuration/theme.php.
Reference
What a View Can Call
Both tag engines run in a sandbox with an empty class allowlist. Nine functions are the entire bridge: Smarty named, Twig positional.
| Function | Smarty | Twig | Returns |
|---|---|---|---|
link | {link route='x' p1='a' p2='b'}{link page='pages/1'} | link('x', null, 'a', 'b') | A client URL for a route key, or a stored page's target |
lang | {lang key='k' foo='bar'} | lang('k') | The theme's translation of k. Extra Smarty parameters fill {foo}; Twig takes the key |
asset | {asset path='css/x.css'} | asset('css/x.css') | URL under the theme's assets/, versioned for css and js |
config | {config key='favicon'} | config('favicon') | One value from the theme configuration. A key with a slash returns an empty string |
money | {money amount=$v currency=$c} | money(v, c) | The amount with its symbol; currency defaults to the visitor's |
hook | {hook name='ui:client.head.css'} | hook('ui:client.head.css') | Every listener's string return |
captcha | {captcha area='a' tray='t' class='' force=false} | captcha('a', 't', '', false) | The active provider's widget, or empty when captcha is off there |
csrf | {csrf form='key'} | csrf('key') | The hidden token input for that form key |
content | {content var=$page.content} | content(page.content) | Operator authored HTML, printed raw. Template syntax inside it is compiled |
The Smarty sandbox also permits twenty plain PHP functions. Twig has no PHP access at all: twelve tags and seventeen filters. Anything else raises a sandbox error.
// Smarty: the only PHP functions a view may call
count sizeof nl2br number_format htmlspecialchars strip_tags
strlen mb_strlen substr mb_substr str_contains str_starts_with
str_ends_with ucfirst date time implode explode in_array is_array
// Twig: the only tags
if for set block with apply autoescape verbatim extends include use embed
Escaping is on by default: Smarty unless you append nofilter, Twig unless you pipe through raw.
Example
The complete theme, four files.
return [
'meta' => [
'name' => 'Acme',
'version' => '1.0.0',
'author' => 'Acme Ltd',
'website' => 'https://acme.example',
'image' => 'cover.png', // beside this file, not under assets/
],
'update-url' => '', // empty while you build: no version check at all
'engine' => 'smarty', // decides the extension of EVERY view
'status' => 'development', // preview only; set to 'ready' when it can be activated
'settings' => [
'groups' => [],
'fields' => [],
],
];
<!doctype html>
{* $ui_lang and $ui_dir arrive on every render; writing them by hand breaks RTL packs. *}
<html lang="{$ui_lang}" dir="{$ui_dir}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{block name=title}{$page_title|default:$company_name}{/block}</title>
<link rel="icon" href="{asset path='favicon.svg'}">
<link rel="stylesheet" href="{asset path='css/default.css'}">
{* Page specific CSS goes here. Page specific SCRIPTS do not: see below. *}
{block name=head}{/block}
{hook name='ui:client.head.css'}
</head>
<body>
{hook name='ui:client.body.begin'}
<header class="site-header">
<a href="{link route='home'}">{$company_name}</a>
</header>
<main>{block name=content}{/block}</main>
<footer>© {$current_year} {$company_name}</footer>
{* Core scripts first, then the page's own: defer executes in document order. *}
<script src="{asset path='js/default.js'}" defer></script>
{block name=scripts}{/block}
{* Modals live here, outside <main>, so a fixed overlay is not trapped in its stacking context. *}
{block name=body_end}{/block}
{hook name='ui:client.body.end'}
</body>
</html>
{extends file='layouts/default.tpl'}
{block name=title}{lang key='home_title'} | {$company_name}{/block}
{block name=head}
<link rel="stylesheet" href="{asset path='css/home.css'}">
{/block}
{block name=content}
<section class="hero">
<h1>{lang key='home_headline' brand=$company_name}</h1>
<p>{lang key='home_lead'}</p>
<a class="btn" href="{link route='sign-in'}">{lang key='home_cta'}</a>
</section>
{/block}
return [
// Read by the theme card in the panel, ahead of meta.name / meta.description.
'name' => 'Acme',
'description' => 'A minimal starter theme.',
// The home view's strings. {brand} is filled by the tag: {lang key='home_headline' brand=$company_name}
'home_title' => 'Home',
'home_headline' => 'Everything {brand} runs, in one place.',
'home_lead' => 'Hosting, domains and licences from a single account.',
'home_cta' => 'Sign in',
];
Set status to 'ready' when Activate should work. Until then the theme is previewable but not activatable.
Pitfalls
The platform catches everything a template engine throws and returns an empty string. A typo or an unclosed block produces an empty page. Turn developer on to see it.
{extends file='layouts/default.tpl'}, not a path relative to the view: the template directory is the theme directory.
Every platform class is unreachable from a view. A theme that needs the whole language declares engine => 'php'.
Markup that appears twice belongs in components/, shell pieces in partials/.
Controllers prepare the data as named variables. When a surface needs more, add a listener in the theme's hooks file.
Related Articles
Merci pour votre retour !
Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.