Your First Theme

2 views Markdown

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/website and temp, 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.
  • developer turned on in coremio/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.

RequirementChecked whenIf it is not met
theme.php existsActivating, and by the cardThe card appears with no engine and an Unknown author; activation refuses it
status is not 'development'Activating, firstRefused with the development message; preview still works
layouts/default.*Activating, after the status checkRefused with the incomplete package message
views/home.*Activating, after the status checkSame: the two files are checked as a pair
locale/{lang}.phpNeverOptional; 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

  1. Create templates/website/Acme/. The folder name is the theme's identity.
  2. Create layouts/, views/, locale/ and assets/css/ in it.

Reload the theme screen. The card shows an empty folder: placeholder cover, folder name, Unknown author.

2. Write the Manifest

  1. Create templates/website/Acme/theme.php returning the array below.
  2. Set engine deliberately: it decides every view's extension. Leaving it out means plain PHP.
  3. Leave update-url empty 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

  1. Create layouts/default.tpl: the document, and the blocks a view fills.
  2. Declare five blocks: title, head, content, scripts, body_end. The shipped themes use these names.
  3. 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

  1. Create views/home.tpl, extending the layout and filling content.
  2. 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

  1. Create locale/en.php: a flat map of key to text.
  2. Put name and description in it; the card reads these before the manifest.
  3. 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

  1. Change status to 'ready' in theme.php. While it says 'development' the button is refused.
  2. Press Activate on the Acme card in {admin}/settings/theme?group=theme.
  3. 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.

FunctionSmartyTwigReturns
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.

sandbox allowlists
// 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.

templates/website/Acme/theme.php
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' => [],
    ],
];
templates/website/Acme/layouts/default.tpl
<!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>&copy; {$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>
templates/website/Acme/views/home.tpl
{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}
templates/website/Acme/locale/en.php
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

A missing view is a blank page, not an error

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.

Layouts and partials are referenced from the theme root

{extends file='layouts/default.tpl'}, not a path relative to the view: the template directory is the theme directory.

The view has nine functions and no classes

Every platform class is unreachable from a view. A theme that needs the whole language declares engine => 'php'.

Do not build the second surface by copying the first

Markup that appears twice belongs in components/, shell pieces in partials/.

A view never queries anything

Controllers prepare the data as named variables. When a surface needs more, add a listener in the theme's hooks file.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.