# Theme Anatomy

https://dev.wisecp.com/es/theme-anatomy

Every key a theme can declare in `theme.php`, and which directory holds what.

## Overview

A theme is a directory and a manifest: a plain PHP array returned from `theme.php`.

`theme.php` is the **schema**, `config.php` the **values**. An upgrade replaces the first and never touches the second.

## Structure

### The Directories

Counts are from the shipped `WStyle` theme.

| Directory | Holds | Required |
| --- | --- | --- |
| `theme.php` | The manifest array | Yes |
| `layouts/` | 4 shells: default, auth, checkout, invoice | `default.*` only |
| `views/` | 63 surfaces in 6 areas, plus 6 loose files | `home.*` only |
| `partials/` | 16 pieces the layouts assemble | No |
| `components/` | 33 shared blocks, plus `home/` with 10 sections | No |
| `tables/` | 7 column presets, always `.php` | No; raw columns without it |
| `assets/` | `css/`, `js/`, `images/`, favicon, libraries | No |
| `locale/` | Author defaults, per language and scope | No |
| `content/` | Operator edits, written from the panel | No. Created on first save |
| `hooks.php` | The theme's listeners, included at boot | No |
| `config.php` | Saved setting values | No. Created on first save |

`tables/` holds plain PHP even in a Smarty or Twig theme: a preset is not a view. The list component includes it with a `$table` variable in scope. The file name is the preset the controller asked for.

### Inside views/

Views are grouped by area. A controller asks for a path without prefix or extension; the engine adds both.

```bash
views/
├── account/     25   the client area: dashboard, services, invoices, tickets, settings
├── auth/         6   sign in, sign up, forgotten password, reset, activate, accept invite
├── checkout/    14   configure, cart, checkout, pay, order complete
├── content/     14   knowledge base, news, contact, legal
├── products/     3   catalog surfaces
├── page/         1   file based pages: views/page/{slug} answers /{slug}
├── home.tpl          the one view apply_theme insists on
├── 404.tpl           theme's own not-found surface
├── maintenance.tpl   rendered while the site is closed
└── domain.tpl        the public domain search

render("account/dashboard")  ->  views/account/dashboard.tpl   (smarty)
                             ->  views/account/dashboard.twig  (twig)
                             ->  views/account/dashboard.php   (php)
```

## Reference

### Top Level Keys

| Key | Type | What it decides |
| --- | --- | --- |
| `engine` | string | `smarty`, `twig` or `php`. Sets the view extension and the sandbox; missing means `php` |
| `meta` | array | The catalogue entry plus the behaviour flags core reads |
| `status` | string | `ready` or `development`. Previewable but not activatable. Missing means ready |
| `update-url` | string | Where the version check posts. Empty, or no `meta.version`, means never checked |
| `settings` | array | The schema: `groups` and `fields`. The admin form is generated from it |

### The meta Block

Read by the theme list, the detail panel and the update check.

- **name**: Display name; the locale file wins.
- **version**: Installed version, sent as `installed-version`. Missing disables the check.
- **description**: One sentence for the catalogue card; the locale file wins.
- **image**: Card thumbnail, resolved **relative to the theme directory**, not `assets/`.
- **author, provider**: Two names for one field; `provider` wins.
- **website, providerUrl**: Author's address. Here `website` wins over `providerUrl`.
- **commercial, premium, price, period**: Either flag makes the card paid; `price` and `period` label it.
- **official, license, support, updates, features**: Detail panel rows; `license` defaults to `Open Source`.
- **docsUrl, supportUrl, demoUrl, purchaseUrl, learnMoreUrl**: Detail panel buttons, printed only when set.

### Behaviour Flags in meta

Core reads these to decide how it behaves.

- **signup_minimal**: Boolean. Set it when the register view asks for the core fields only; requirement settings for the omitted fields are then not enforced.
- **dashboard_due_soon_alert**: Boolean. Off, the dashboard reminder strip carries the overdue invoice only. On, also the next due invoice.
- **disabled_routes**: Route **keys** the theme does not serve: those addresses answer 404 and leave the sitemap.

### settings.groups

Group key to a two key descriptor. Groups only sort the admin form.

```php
$settings = [
    'groups' => [
        // group key      label = a key in the theme's locale file, NOT literal text
        'appearance' => ['label' => 'grp_appearance', 'icon' => 'bi-palette'],
        'checkout'   => ['label' => 'grp_checkout',   'icon' => 'bi-cart3'],
    ],
];
```

### settings.fields

Setting key to a descriptor: the POST name, the config key and the name views read.

- **type**: One of the six below; anything else is a text field saving a string.
- **group**: Key of the group. An undeclared group still appears, ungrouped.
- **label, desc, placeholder**: Keys into the theme's own locale file, with an English fallback.
- **default**: Used until the operator saves one, and when a submission is invalid.
- **options**: Select only, as `value => label key`. Also the allowlist: unknown values fall back to the default.
- **depends**: `other field => required value`. The row stays collapsed until every condition matches. A hidden field is still saved.
- **multilang**: Text and textarea only. One tab per active language, saved as `lang => value`.
- **rows**: Textarea height, default 4.
- **html, allowed_tags**: Text and textarea only. Without `html => true` every tag is stripped on save; with it the value is sanitized against `allowed_tags`.
- **from_logo**: Color only, an integer index. Groups the field into the brand colour pair and adds the "pick from logo" action.

### Field Types and What They Save

| type | Control | Saved value |
| --- | --- | --- |
| `switch`, `checkbox` | Checkbox, description as label | Real boolean `true` / `false` |
| `color` | Swatch plus a typable hex box | `'#rrggbb'`. 3-8 hex digits, else the default |
| `number` | Number input | Integer, cast |
| `select` | Dropdown built from `options` | The chosen key, or the default |
| `textarea` | Textarea, or language tabs with `multilang` | String, or a `lang => string` map |
| anything else | Text input, or language tabs with `multilang` | String, or a `lang => string` map |

### Reading the Manifest

```php
// The whole manifest of ANY theme, by folder name. Cached per name, empty array when absent.
public static function manifest(string $themeName): array;

// The running theme. Falls back to Basic when the configured folder is gone.
public static function active(): self;

public function meta(): array;                    // manifest['meta']
public function engine(): string;                 // lowercased, 'php' when unset
public function settings(): array;                // manifest['settings']
public function settingsSchema(): array;          // the SAME array as settings()
public function exists(): bool;                   // manifest is non-empty AND the directory is there

// Values.
public function savedConfig(): array;             // config.php, cached per instance
public function setting(string $key): mixed;      // saved value, else the field's default, else null
public function allSettings(): array;             // every schema field, merged, as views receive it

public static function is_shipped(?string $name = null): bool;   // one of the shipped themes (Basic, WStyle)
public static function engineLabel(string $engine): string;      // 'smarty' -> 'Smarty', for the panel
```

`allSettings()` is what views get as `$setting`: a multilang field as the active language's string, a colour field twice (`primary_color` plus `primary_color_rgb`).

## Example

### One Field, Declared

A promotional strip: a switch that gates a rich text field.

```php
return [
    'meta' => [
        'name'        => 'Acme',
        'version'     => '1.0.0',
        'author'      => 'Acme Ltd',
        'website'     => 'https://acme.example',
        'image'       => 'cover.png',      // beside theme.php, NOT under assets/
        'description' => '',               // locale/en.php wins, so it is left empty here

        // Behaviour flags: read by core, not by the catalogue.
        'signup_minimal'           => false,
        'dashboard_due_soon_alert' => false,
        'disabled_routes'          => ['references', 'references_detail'],
    ],
    'update-url' => '',                    // empty: never checked for updates
    'engine'     => 'smarty',
    'status'     => 'ready',
    'settings'   => [
        'groups' => [
            'topbar' => ['label' => 'grp_topbar', 'icon' => 'bi-megaphone'],
        ],
        'fields' => [
            'topbar_enabled' => [
                'type'    => 'switch',
                'group'   => 'topbar',
                'label'   => 'set_topbar_enabled',      // a key in locale/{lang}.php
                'desc'    => 'set_topbar_enabled_desc',
                'default' => false,
            ],
            'topbar_text' => [
                'type'      => 'textarea',
                'group'     => 'topbar',
                'label'     => 'set_topbar_text',
                'default'   => '',
                'rows'      => 3,
                'multilang' => true,        // one tab per active language
                'html'      => true,        // otherwise every tag is stripped on save
                'depends'   => ['topbar_enabled' => true],
            ],
        ],
    ],
];
```

```php
return [
    // The catalogue card reads these two from here, not from meta.
    'name'        => 'Acme',
    'description' => 'A compact storefront theme with a promotional strip.',

    'grp_topbar'              => 'Promotional Strip',
    'set_topbar_enabled'      => 'Show the strip',
    'set_topbar_enabled_desc' => 'Prints a single line above the header on every public page.',
    'set_topbar_text'         => 'Strip content',
];
```

### The Same Field, Read Back

The panel writes the values; the theme reads them.

```php
return [
    'topbar_enabled' => true,
    'topbar_text'    => [
        'en' => '<strong>Launch week</strong> 20% off every plan.',
        'tr' => '<strong>Lansman haftası</strong> tüm planlarda %20 indirim.',
    ],
];
```

```smarty
{* $setting.topbar_text is already the active language's string, not the map. *}
{if $setting.topbar_enabled}
    <div class="topbar">{$setting.topbar_text nofilter}</div>
{/if}
```

```php
// setting() returns the RAW stored value: a multilang field is still the lang => value map here.
$strip = Theme::active()->setting('topbar_text');
$lang  = Language::selected() ?: 'en';
$text  = is_array($strip) ? ($strip[$lang] ?? $strip['en'] ?? '') : (string) $strip;

// allSettings() is the resolved form, which is why the templates never do the above.
$resolved = Theme::active()->allSettings();
$text     = (string) ($resolved['topbar_text'] ?? '');
```

## Pitfalls

> **config.php is not yours to write**
> 
> Generated on save and left alone by an upgrade. Hand edits survive only until the operator presses save.

> **A value the schema does not declare never reaches a view**
> 
> Merged settings are built from the schema fields, not the saved file, so a stray key never reaches the templates.

> **Labels are locale keys**
> 
> A settings screen showing `set_topbar_enabled` means the key is missing from the locale file.

> **settings() and settingsSchema() are the same array**
> 
> Both return the manifest's settings block. The processed form is allSettings().

> **meta.image is relative to the theme, not to assets/**
> 
> The shipped value is a bare `cover.png` next to the manifest. A leading slash produces a broken card.

## Related Articles

- [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine)
- [Your First Theme](https://dev.wisecp.com/en/your-first-theme)
- [Theme Settings](https://dev.wisecp.com/en/theme-settings)
- [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme)
- [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters)
