---
name: vtpl-building
description: Reference for the Vtpl template engine (.tpl command files) — CSS-selector-based bindings compiled against plain HTML. Use when writing or editing admin/app/plugin .tpl templates, theme template/ overrides, wiring dynamic data with selectors, placeholders (@@__attr__@@), data-filter-* filters, @@macro calls, import/from reuse, conditionals (data-v-if), or debugging templates that don't compile/render.
---

# Vtpl Templating

Vtpl is a **binding language between PHP and HTML**: a `.tpl` file is a list of `selector = value` commands. At compile time each command's value is injected into the HTML document matched by its CSS selector — the HTML itself is never edited and contains no PHP.

```
admin/template/message.tpl  +  plugins/contact-form/public/admin/message.html
        └── Vtpl compile ──►  storage/compiled-templates/{app}_{SITE_ID}_{theme}_{path}   ← cached PHP
```

Reference material: `docs/developer/theme/vtpl.md`, engine source `system/vtpl/vtpl.php` (+ `macros.php`, `debug.php`). Real-world examples: `admin/template/crud.tpl` (forms/loops), `app/template/common.tpl` (site-wide bindings incl. `data-v-if` macros), `app/template/components/*.tpl` (list components), `plugins/*/admin/template/*.tpl`.

**Never edit files in `storage/compiled-templates/`.**

## Workflow

1. Find the closest existing `.tpl` (`crud.tpl`, `listing.tpl`, an app content tpl, or a plugin tpl) and copy its patterns.
2. Write commands against stable hooks in the HTML — prefer `data-v-*` attributes over ids/classes (matches page-builder markup).
3. Verify by compiling offline with the harness below; broken PHP never reaches the cache (syntax check runs before write).

## Compile flow & caching

Handled by `Vvveb\System\Core\View::compile()`:

- The HTML source is loaded from the controller's template path (theme page, plugin `public/` html, …). Theme `template/` folders are added as **first-priority** search paths for tpl resolution.
- Tpl lookup: `<app>/template/<name>.tpl`; for plugins, `plugins/<slug>/<app>/template/<name>.tpl`; if nothing matches → `common.tpl`.
- Recompiles automatically when tpl **or** HTML mtime is newer than the compiled file.
- Hooks: `Event::on('Vvveb\System\Core\View', 'compile'|'compile:after', ...)` can swap tpl/html paths.
- Compiled file name: `{app}_{SITE_ID}_{theme}_{template_path}` under `DIR_COMPILED_TEMPLATES`.

## Command syntax

Each line: `selector|modifier = value`. Lines without `=` are modifier-only commands (`div.x|delete`). Comma-separated selectors apply to all of them (`h1, h2|deleteAllButFirst`). Comments: `//line` and `/* block */`. A value must start on the same line as `=` (multiline PHP blocks are fine — they're extracted before line parsing).

Value types (auto-detected):

| Value | Compiles to |
|---|---|
| `"string"` | inserted verbatim |
| `$this->var` / `$post['x']` | `if (isset(...)) echo htmlspecialchars(...);` |
| `<?php ... ?>` | raw PHP, verbatim |
| `from(file.html\|selector)` | copies live HTML from another document |

```css
head > title = $this->post['title']                          // escaped echo into innerHTML
a[data-v-post-url]|href = $post['url']                        // unknown modifier = attribute assignment
[data-v-post-content] = <?php echo $post['content']; ?>       // raw php (no escaping)
img|src = <?php echo getScaledImage('@@__src__@@', '@@__data-size__@@'); ?>
```

## Modifiers

No modifier = `innerHTML` (default). Full set (dispatched in `processElements()`):

| Modifier | Action |
|---|---|
| `outerHTML` | replace entire element(s) |
| `text` / `innerText` | replace first text node only |
| `before` / `after` | insert outside, before/after element(s) |
| `prepend` / `append` | insert inside, at start/end |
| `delete` | remove element(s) |
| `deleteAllButFirst` | keep 1st occurrence globally (dedupe mockup rows) |
| `deleteAllButFirstChild` | keep 1st child per parent (loop template) |
| `if_exists = $var` | wrap element in `if (isset($var) && $var)` |
| `hide = $var` | inverse of if_exists |
| `addClass` / `removeClass` | modify class attribute |
| `addNewAttribute` | append raw `name="value"` output (used for conditional attrs) |
| `removeAttribute` | drop an attribute |
| *anything else* | treated as an **attribute name** (`href`, `src`, `value`, `data-v-id`, …) |

Loop idiom (repeat one mockup element per row):

```css
[data-v-data]|deleteAllButFirstChild
[data-v-data]|before = <?php foreach ($this->message['message'] as $name => $value) { ?>
	[data-v-data-name]  = $name
	[data-v-data-value] = $value
[data-v-data]|after = <?php } ?>
```

## Selector aliases

Define reusable selector prefixes inline — lines matching `@name = selector` store an alias, later uses expand it:

```css
@post  = [data-v-component-post]
@image = [data-v-component-post] [data-v-post-images] [data-v-post-image]

@post [data-v-post-*]|innerText = $post['@@__data-v-post-(*)__@@']
@image|deleteAllButFirstChild
```

## Placeholders `@@__...__@@`

Replaced **at compile time** with values from the matched node (works inside PHP code, strings and attribute assignments):

| Placeholder | Resolves to |
|---|---|
| `@@__innerHTML__@@` / `@@__innerText__@@` | node's html/text (JSON pretty-printed if it starts with `{`) |
| `@@__attr-name__@@` | node's attribute value |
| `@@__data-v-product-(*)__@@` | capture group from a matching attribute **name** (`data-v-product-title` → `title`) |
| `@@__data-v-x:data-v-x-(.+)__@@` | regex applied to the attribute's value/name after `:` |

Catch-all binding — one line wires every `data-v-*` leaf:

```css
@post [data-v-post-*]|innerText = $post['@@__data-v-post-(*)__@@']
@post img[data-v-post-*]|src    = $post['@@__data-v-post-(*)__@@']
```

## Filters `data-filter-*`

Designer-controlled transforms declared on the HTML node; applied to whatever value the tpl inserts (they wrap the generated `echo htmlspecialchars(...)` chain). Options are passed via the attribute value; multiple filters chain in attribute order.

```html
<span data-filter-friendly_date>2024-06-01</span>
<p data-filter-truncate="5">long text</p>
<img data-filter-date="Y/m/d">
```

Available (source of truth: `$variableFilters` in `system/vtpl/vtpl.php`): `capitalize` (**not** `ucfirst`), `uppercase`, `lowercase`, `strip_html`, `strip_newlines`, `html_to_text`, `friendly_date`, `friendly_number`, `human_readable`, `date` (format arg), `truncate`/`truncate_words` (length arg), `replace` (search, replace args), `append`/`prepend`, `number_format` (4 args), `only_decimals`, `without_decimals`, plus class/tag specials (`mod_class`, `conditional_class`, `iteration_class`, `mod`).

## JSON attributes & json-path

Any node attribute whose value starts with `{` becomes a compile-time constant tree; read it with `@@path.to.key@@`:

```html
<div data-my-json='{price:{min:"10"}}'>…</div>
```
```css
[data-my-json] = <?php echo '@@my-json.price.min@@'; ?>   // prints "10"
```

## Macros

Compile-time function calls evaluated while the template compiles: `@@macro Name("arg")@@` invokes PHP function `vtplName(Vtpl $vtpl, DOMNode $node, ...$args)` and replaces itself with the return value. Built-ins in `system/vtpl/macros.php`: `IfCondition`, `IfClass`, `IfAttr`, `KeyValue`, `Escape`, `postNameToArrayKey`.

## Conditionals (HTML side)

Implemented via `import(ifmacros.tpl)` in `common.tpl` + the macros above — not hardcoded in the engine:

- `data-v-if="expr"` / `data-v-if-not="expr"` — show/hide element
- `data-v-class-if-class_name="expr"` / `data-v-class-if-not-…` — conditional classes
- `data-v-attr-if-attr_name="expr"` / `data-v-attr-if-not-…` — conditional attributes

Expression syntax (compiled by `ifCondition()`): dot notation becomes array access with automatic `isset()` guards (`posts.count` → `(isset($posts['count'])) && ($posts['count'])`), bare `=` becomes `==`, `this.x` → `$this->x`. Function-call-looking tokens are stripped, so keep expressions to comparisons/logic only.

## Interpolation `{$var}`

Processed last, over the final HTML — so it works everywhere including restored `<script>` blocks. Arrays become JSON:

```html
<script>
let date_created = '{$this.date_created}';
let cfg = {$this.my_object};
</script>
<a title="Created at {$this.date_created}">…</a>   <!-- attribute interpolation -->
```

Optional pipe modifiers: `{$var|htmlspecialchars|trim}`.

## Imports & from()

```css
import(common.tpl)                                  /* resolved across template paths */
import(crud.tpl, {"type":"message"})                /* {{type}} substituted in the imported text */
import(/plugins/contact-form/admin/template/x.tpl)  /* absolute from DIR_ROOT */
import(listing.tpl, [data-v-list])                  /* conditional: only if selector matches */
```

Paths starting `/plugins/` resolve into `DIR_PLUGINS`; imports may nest; `*` globs are expanded. Missing files log a debug entry and contribute nothing.

`from()` copies rendered HTML out of another document — the mechanism behind `data-v-save-global` / `data-v-copy-from` in `common.tpl`:

```css
[data-v-save-global]|outerHTML = from(@@__data-v-save-global:([^\,]+)__@@|@@__data-v-save-global:[^\,]+\,([^\,]+)__@@)
```

## What happens automatically (don't re-implement)

- Text nodes are wrapped with `\Vvveb\__()` for translation — skipped inside `textarea/script/code/iframe/noscript`, URLs, numbers, texts <2 chars or containing `{$`/`<?php`. `placeholder`/`title` attributes are translated too.
- Relative `src/href/srcset/style url()` are rebased to the theme path.
- `{$var}` in attributes becomes escaped echoes.
- A missing `<base href="">` is injected.
- PHP tags in the **HTML source** are stripped (`VTPL_DONT_ALLOW_PHP`) — logic belongs in the `.tpl`, not the HTML.
- Generated PHP is syntax-checked (`token_get_all`) before writing; a broken tpl returns a parse-error page instead of poisoning the cache.

## Debugging

- Global `VTPL_DEBUG` (env.php) enables the on-page console listing every injection; per-file toggle with `debug = true` / `debug = false` lines in the tpl.
- Debug logs show selector match counts — `[0 elements]` means your selector didn't match the HTML.
- Inspect the compiled output directly in `storage/compiled-templates/` to see exactly what a command produced.
- Current WIP note: `system/vtpl/vtpl.php`'s new-`Dom\` branch fatals on PHP ≥ 8.4 (DOMNode type mismatch) and has stray `var_dump`s in `setMultiLanguageText()`; offline harnesses should force the legacy path (below) until that's fixed.

## Offline harness (no web server)

```php
<?php // /tmp/vtpltest/run.php
namespace {
    define('DIR_ROOT', '/home/www/vvveb/vvveb/');
    define('DS', '/');
    define('PUBLIC_PATH', DIR_ROOT . 'public/');
    define('APP', 'admin');
    foreach (['arrays','debug','text','html','url'] as $m)
        require DIR_ROOT . 'system/functions/' . $m . '.php';
}

namespace Vvveb\System\Core {   // stubs used by saveCompiledTemplate()
    function exceptionToArray($e, $file = '') { return []; }
    class FrontController { public static function notFound($exit = false, $d = [], int $s = 404) { return null; } }
}

namespace {
    require DIR_ROOT . 'system/vtpl/vtpl.php';

    // force legacy DOMDocument path on PHP >= 8.4
    function forceLegacy($vtpl) {
        $doc = new DOMDocument();
        foreach ([['document', $doc], ['legacy', true]] as [$p, $v]) {
            $r = new ReflectionProperty($vtpl, $p); $r->setValue($vtpl, $v);
        }
        $r = new ReflectionProperty($vtpl, 'xpath'); $r->setValue($vtpl, new DOMXPath($doc));
    }

    $vtpl = new \Vtpl();
    forceLegacy($vtpl);
    $errors = $vtpl->loadHtmlTemplate('/path/to/page.html');     // the HTML source
    $vtpl->addTemplatePath(DIR_ROOT . 'admin/template/');
    $vtpl->loadTemplateFile('/path/to/template.tpl');
    $ok = $vtpl->saveCompiledTemplate('/tmp/compiled.php');      // false => parse error
    var_dump($ok, $errors);

    // render in object scope like View does ($this = controller data)
    $fake = new class { public $post = ['title' => 'Hello']; };
    (function () { include '/tmp/compiled.php'; })->call($fake);
}
```

## Linting compiled templates

Compiled templates are plain PHP files without a `.php` extension — `php -l` works on them regardless. Filenames map back to their sources:

```
storage/compiled-templates/app_1_landing_content_index.html
└ app      └ SITE_ID   └ theme   └ template path (theme page or controller template)
```

`.component` suffixed variants are per-component extractions of the same page.

Lint one file:

```bash
php -l storage/compiled-templates/app_1_landing_content_index.html
```

Lint everything, printing only failures (exit code reflects result):

```bash
cd /home/www/vvveb/vvveb
fail=0; while IFS= read -r f; do out=$(php -l "$f" 2>&1) || { echo "$out"; fail=1; }; done \
  < <(find storage/compiled-templates -type f ! -name '.*'); exit $fail
```

A clean run prints nothing. A failure looks like:

```
PHP Parse error:  Unclosed '{' on line 1 in ... on line 2
Errors parsing ...
```

Typical causes: mismatched `|before`/`|after` PHP around loops (`foreach` opened but not closed), unbalanced `<?php … ?>` in a command value, or a stale/partially-written cache file.

Notes:

- The engine already runs `token_get_all(..., TOKEN_PARSE)` before writing a compiled file, so **fresh** compiles are syntax-checked — batch linting catches stale cache from older engine versions, interrupted writes, and regressions after tpl/engine edits.
- To re-lint fresh output for one page without a web request, either delete the compiled file (recompiles automatically on next request) or generate it with the harness above into `/tmp/compiled.php` and lint that.
- Linting validates syntax only — it will not catch wrong selectors or runtime warnings. Render via the harness for that.

Checklist before committing tpl changes:

1. Every command matches real markup — run the harness and grep the compiled output for your selectors' effects (and `[0 elements]` surprises via debug log).
2. Values that must not be escaped use explicit `<?php echo ... ?>`, not variables.
3. Loop templates pair `|before`/`|after` with `foreach`, and `|deleteAllButFirstChild` on the repeating element.
4. New `data-filter-*` names exist in `$variableFilters` (misspellings fail silently with only a debug warning).
5. No PHP introduced into HTML sources; no edits to `storage/compiled-templates/`.
6. Batch lint of `storage/compiled-templates/` passes (and harness-generated output passes `php -l`) for every page/template you touched.
