demeve/template

A PHP library to load and render view files split into named sections (output, style, script, etc.).

Maintainers

Package info

github.com/demve/template

pkg:composer/demeve/template

Transparency log

Statistics

Installs: 61

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.10.0 2026-05-27 03:42 UTC

This package is auto-updated.

Last update: 2026-06-27 03:49:17 UTC


README

PHP 8.1+ License: MIT Packagist

A lightweight PHP template engine built around Single File Components — HTML, CSS, and JS in one file, split into named sections.

What is this?

Demeve Template lets you build PHP views as self-contained component files. Each file is divided into named sections (output, style, script, etc.) using HTML comment markers. The engine parses these files once and writes per-section cache files that are plain PHP — eligible for OPcache and easy to inspect.

Components can extend layouts, include other components, and accumulate style/script sections from every component on the page into a single emission point. There is no template syntax to learn beyond a handful of comment directives; everything else is native PHP.

Features

  • Single File Components: HTML, CSS, and JS in one .php or .html file
  • Layout inheritance: <!--extends::Layout--> with named slots and accumulated blocks
  • Comment directives: IDE-friendly syntax that compiles to PHP calls
  • CSS scoping: __self placeholder replaced with the component name — no collisions
  • Smart caching: per-section .cache.php files, invalidated by mtime
  • Modifiers: post-process sections (minify, annotate, deduplicate)
  • File modifiers: bundle CSS/JS to disk with cache-busting URLs
  • Auto-load: dependencies discovered from render() calls, no manual declaration needed
  • Autoload folder: drop components in autoload/ to inject global CSS/JS without any <!--load::--> declaration
  • Asset management: copy assets to public directory with mtime-based versioning
  • Preview mode: built-in interactive component browser with live controls

Installation

composer require demeve/template

Requires PHP 8.1 or higher. No other dependencies.

Quick Start

1. Create a layout (components/layout.php):

<!--section::output-->
<!DOCTYPE html>
<html>
<head>
    <title><?= $this->slot('title', 'My App') ?></title>
    <?= $this->block('style') ?>
</head>
<body>
    <?= $this->slot('content') ?>
    <?= $this->block('script') ?>
</body>
</html>

2. Create a component (components/home.php):

<!--extends::Layout-->

<!--section::title-->Welcome

<!--section::style-->
<style>
.__self { padding: 2rem; }
.__self h1 { font-size: 2rem; }
</style>

<!--section::output-->
<div class="__self">
    <h1>Hello, <?= $this->e($name) ?>!</h1>
</div>

3. Render it:

use Demeve\Template\Template;

$t = new Template(
    path:  __DIR__ . '/components',
    cache: __DIR__ . '/.cache',
);

echo $t->render('Home', ['name' => 'World']);

Core Concepts

Components & Sections

A component is a single .php or .html file divided into named sections using <!--section::name--> markers. The output section is the rendered HTML. Any other name (style, script, meta, etc.) is collected globally and emitted elsewhere.

<!--section::style-->
<style>
    .__self { color: red; }
</style>

<!--section::output-->
<p class="__self">Hello</p>

<!--section::script-->
<script>
    console.log('loaded');
</script>

Sections accumulate across all loaded components. Every style block from every widget on the page can be emitted together with a single $this->block('style') call in the layout.

Comment Directives

Directives are HTML comments that compile to PHP calls. They keep files syntax-highlighted in any editor.

Syntax Compiles to Purpose
<!--section::name--> section boundary Declare a named section
<!--extends::Layout--> layout declaration Must appear before any section
<!--load::Component--> pre-load call Register sections without rendering output
<!--fn::arg--> <?php $this->fn("arg"); ?> Side-effect call
<!--=fn::arg--> <?php echo $this->fn("arg"); ?> Echo call
<!--fn::arg1|arg2--> multi-argument call Pipe-separated arguments
<!---dev note---> stripped Never reaches cache

Common shorthand examples:

<!--=render::WidgetsCard-->             renders a sub-component
<!--=slot::title|Default Title-->       emits a slot with fallback
<!--=block::style-->                    emits accumulated style sections
<!--=block::style|css-->                emits with modifier applied
<!--=files::style|css-->                emits via file modifier
<!--print::name-->                      echoes $this->e($this->get('name'))

CSS Scoping with __self

Use __self as the root class in every component's style section and HTML markup. The parser replaces it with the component name verbatim (as passed to render()/load()):

Component __self becomes
WidgetsCard WidgetsCard
PageHeader PageHeader
Button Button

Note: Use PascalCase component names (no dots). WidgetsCard.__self becomes .WidgetsCard — a single valid CSS class. Dots produce compound selectors that don't work as class names.

<!--section::style-->
<style>
.__self          { display: flex; gap: 1rem; }
.__self .title   { font-size: 1.25rem; font-weight: bold; }
</style>

<!--section::output-->
<div class="__self">
    <h2 class="title"><?= $this->e($title) ?></h2>
</div>

Do not use __self for global reset styles or intentionally shared utility classes.

Layouts & Inheritance

A component declares its parent with <!--extends::LayoutName--> on the first line. The layout wraps the child's output automatically.

Child (components/pages/home.php):

<!--extends::LayoutsApp-->
<!--load::WidgetsCard-->

<!--section::title-->Home Page

<!--section::output-->
<main>
    <?= $this->render('WidgetsCard', ['title' => 'Hello']) ?>
</main>

Layout (components/layouts/app.php):

<!--section::output-->
<!DOCTYPE html>
<html>
<head>
    <title><?= $this->slot('title', 'App') ?></title>
    <?= $this->block('style') ?>
</head>
<body>
    <?= $this->slot('content') ?>
    <?= $this->block('script') ?>
</body>
</html>

Layouts can themselves extend other layouts. The chain resolves automatically.

Circular extends (A extends B, B extends A) are detected at load time: the engine logs an error via errors() and breaks the cycle rather than looping forever.

Slots vs Blocks

slot() block()
Purpose Single value from one component Accumulated from all loaded components
Typical use Page title, main content CSS, JS, meta tags
Template call $this->slot('title', 'Default') $this->block('style')
Directive <!--=slot::title|Default--> <!--=block::style-->

block() returns a placeholder string at call time. After the full render tree completes, the engine replaces all placeholders with the accumulated section content. This means components loaded after the block directive fires still contribute their sections correctly.

Modifiers

A modifier post-processes all sections of a given name before emission. Implement ModifierInterface:

use Demeve\Template\ModifierInterface;

class CssMinifier implements ModifierInterface
{
    public function process(array $sections): string
    {
        $combined = implode("\n", $sections);
        $combined = preg_replace('/\/\*.*?\*\//s', '', $combined);
        return trim(preg_replace('/\s+/', ' ', $combined));
    }
}

$sections is an associative array keyed by component name → raw section content.

Register and use:

$t->addModifier('css', new CssMinifier());
<!--=block::style|css-->

Built-in modifiers in src/Modifier/:

Class Type Effect
CssModifier ModifierInterface Minifies CSS
JsModifier ModifierInterface Minifies JS (handles strings, template literals, regex literals)
HtmlModifier ModifierInterface Prepends HTML comment with component name
CssFileModifier FileModifierInterface Bundles CSS to a single file, returns <link> tag or URL
JsFileModifier FileModifierInterface Bundles JS to a single file, returns <script> tag or URL

File Modifiers (Bundling)

FileModifierInterface receives file paths instead of content, enabling memory-efficient bundling. The modifier reads files only when regeneration is needed.

use Demeve\Template\Modifier\CssFileModifier;
use Demeve\Template\Modifier\JsFileModifier;

$t->addModifier('css', new CssFileModifier([
    'output_dir'        => __DIR__ . '/public/dist',
    'public_url_prefix' => '/dist',
    'read_file'         => false,  // return URL; true returns file contents
]));

$t->addModifier('js', new JsFileModifier([
    'output_dir'        => __DIR__ . '/public/dist',
    'public_url_prefix' => '/dist',
    'read_file'         => false,
]));

In the layout:

<link rel="stylesheet" href="<?= $this->blockFiles('style', 'css') ?>">
<script src="<?= $this->blockFiles('script', 'js') ?>"></script>

Or with directives:

<link rel="stylesheet" href="<!--=files::style|css-->">

The modifier generates a hash-based filename and skips regeneration if the output file is newer than all source cache files.

Auto-load

Dependencies are discovered automatically from render() calls in component output sections. You rarely need <!--load::Component-->.

<!-- page.php — no manual load declarations needed -->
<!--section::output-->
<?= $this->render('WidgetsCard') ?>
<?= $this->render('WidgetsBadge') ?>

load('Page') scans the compiled cache, finds the render() calls, and loads those components (recursively). Their style/script sections are registered before rendering begins.

Use <!--load::Component--> explicitly when you need sections from a component that is not called via render() in output (e.g., a component loaded conditionally at runtime).

Autoload folder

Any component placed inside components/autoload/ is loaded automatically before every render() call — without needing a layout or any <!--load::--> declaration. This is the right place for global CSS resets, CSS custom properties, and general-purpose scripts.

components/
  autoload/
    reset.php       → AutoloadReset        (loaded automatically)
    typography.php  → AutoloadTypography   (loaded automatically)
  card.php          → Card                 (uses CSS vars from autoload/)

Names follow the standard PascalCase convention: autoload/typography.phpAutoloadTypography. These components can also be referenced manually with <!--load::AutoloadTypography--> if needed.

The scan runs once per Template instance (on the first render() call). Preview iframes go through render(), so autoload components are included there automatically too.

Cache Management

The engine caches component sections automatically (mtime-based invalidation). For forced resets — after deployments or during development — there are two ways to clear the cache:

Programmatic:

$t->clearCache(); // deletes all *.cache.php files from the cache directory

Browser GET-param trigger:

On every request, the Template constructor checks $_GET for a configurable param. If it matches, clearCache() is called before any rendering happens. Default trigger:

?__demeve_clear_cache=clear_cache

The param name and expected value are overridable:

$t = new Template(path: 'components/', cache: '.cache/');
$t->setClearCacheParam('flush')->setClearCacheValue('now');
// → triggered by: ?flush=now

Both setters are fluent. No redirect or exit is performed — rendering continues with a cold cache.

Asset Management

Copy assets from the components directory to the public directory with cache-busting:

$t = new Template(
    path:   'components/',
    cache:  '.cache/',
    public: 'public/',
    url:    '/',
);

Inside a component:

<img src="<?= $this->asset('Widgets.Card/hero.jpg', 'images/hero.jpg') ?>">

The file is copied only when the source is newer than the destination. The returned URL includes ?v={mtime}.

Component Naming → File Path

PascalCase names: each uppercase letter marks a new path segment. All files and folders must be all-lowercase. No dots.

Component name Resolved path
Button components/button.php
WidgetsCard components/widgets/card.php
LayoutsApp components/layouts/app.php
PageWithSidebar components/page/with/sidebar.php
Button (directory) components/button/component.php

To keep related pages in the same folder without adding a sub-folder, keep the differentiating word lowercase: PageWithsidebarcomponents/page/withsidebar.php.

Resolution order: if the resolved path is a directory, the engine looks for component.php then component.html inside it; otherwise it looks for path.php then path.html. Directory takes precedence over file when both exist.

Preview Mode

Preview mode is a built-in interactive component browser — no external tools required.

Start it (preview.php):

$t = new Template(path: 'components/', cache: '.cache/');
$t->preview();                  // opens the component grid
$t->preview('WidgetsCard');     // opens directly to a specific component

The optional $component argument takes priority over the ?component= query string. Useful for deep-linking or building custom preview entry points.

Add a preview section to any component:

<!--section::preview-->
<?php $this->registerPreview([
    'title'       => 'Card Widget',
    'description' => 'A flexible card for displaying content.',
    'data' => [
        'title'       => 'Getting Started',
        'body'        => 'Build modern PHP templates.',
        'highlighted' => false,
        'tag'         => 'New',
    ],
    'controls' => [
        'title'       => 'text',
        'body'        => 'textarea',
        'highlighted' => 'boolean',
        'tag'         => 'text',
    ],
]);

Available control types:

Type Input rendered
text Single-line text field
textarea Multi-line text area
number Number input (supports min/max: ['type'=>'number','min'=>1,'max'=>10])
boolean Toggle switch
color Color picker
select Dropdown (['type'=>'select','options'=>['a','b','c']]). Optional labels via pipe: 'value|Label' → shows "value: Label" in dropdown, sends value to component
nested Grouped sub-controls for an associative array value
texts Repeatable single-line text inputs for a flat string array (e.g. ['a','b','c'])
list Repeatable items with add / remove / reorder per item

Array controls:

nested — for a single associative array (e.g. a button sub-component):

'data'     => ['cta' => ['text' => 'Get started', 'url' => '#']],
'controls' => ['cta' => ['type' => 'nested', 'fields' => ['text' => 'text', 'url' => 'text']]],

The sidebar renders cta.text and cta.url as indented controls. Pass the whole array to a sub-component with $this->render('Button', $cta).

list — for a sequential array of same-schema items (e.g. feature cards, links):

'data' => [
    'items' => [
        ['title' => 'Fast',     'description' => 'Optimised at every layer.'],
        ['title' => 'Flexible', 'description' => 'Adapts to your stack.'],
    ],
],
'controls' => [
    'items' => ['type' => 'list', 'fields' => ['title' => 'text', 'description' => 'text']],
],

Each item gets ↑ ↓ × buttons; the list gets a "+ Add item" button. Auto-inferred when controls is omitted.

See examples/10-preview/components/widgets/features.php for a working demo using both types together.

Preview viewer features:

  • Grid view of all components, with a badge for components that have a preview section
  • Split-pane layout: live iframe preview + controls panel
  • Responsive breakpoints — drag the preview handle or select presets
  • Every control change rebuilds the preview URL with updated query parameters
  • Components without a preview section still appear and render normally

Vue SFC Preview Mode

Set 'mode' => 'vue' in the preview config to render the component as a Vue 3 Single File Component. The preview viewer injects the component into a standalone iframe using Vue's CDN build — no bundler required.

Component structure (4 sections):

<!--load::OtherVueComponent-->          <!-- dep: child component (if needed) -->

<!--section::style-->
<style>
.__self { padding: 1.5rem; border: 1px solid #e2e8f0; }
</style>

<!--section::template-->
<dmv-template>
  <div class="__self">
    <h2>{{ title }}</h2>
    <p v-if="showCount">Count: {{ count }}</p>
    <button @click="count++">+1</button>
  </div>
</dmv-template>

<!--section::script-->
<script>
dmvVueComponent({
  props: {
    title:     { default: 'Counter' },
    showCount: { default: true },
    initCount: { default: 0 }
  },
  data() { return { count: this.initCount }; }
});
</script>

<!--section::preview-->
<?php $this->registerPreview([
    'mode'        => 'vue',
    'title'       => 'My Counter',
    'description' => 'Vue 3 counter with reactive internal state.',
    'data' => [
        'title'     => 'Counter',
        'showCount' => true,
        'initCount' => 0,
    ],
    'controls' => [
        'title'     => 'text',
        'showCount' => 'boolean',
        'initCount' => 'number',
    ],
]);

How it works:

Shorthand Expands to
<dmv-template> <script type="text/template" id="template-ComponentName">
</dmv-template> </script>
__self Component name (e.g. VueCounter) — same as PHP components
dmvVueComponent({ window.__dmv.components['ComponentName']=({template:'#template-ComponentName',name:'ComponentName',

The preview viewer collects style/css, template, and script/js sections from the component and all its deps (loaded via <!--load::-->), then mounts Vue with all preview data values bound as props:

// pseudocode of what the viewer generates
Vue.createApp({
  data() { return { data_props: previewData }; },
  template: '<VueCounter v-bind="data_props"></VueCounter>'
}).mount('#previewapp');

Every control change reloads the iframe with the new values. Props declared in dmvVueComponent receive the preview data; internal data() state is reactive per-instance independently.

Child components: declare the dep with <!--load::VueCounter--> at the top of the file. The viewer globally registers all window.__dmv.components entries so child tags resolve inside nested templates.

Extensible preview modes: register custom renderers (React, Svelte, etc.) via addPreviewMode():

$t->addPreviewMode('react', new MyReactPreviewMode());
// component preview: 'mode' => 'react'

Implement Demeve\Template\Preview\PreviewModeInterface:

interface PreviewModeInterface {
    public function renderPreview(string $component, array $data, array $preview): string;
}

Examples

Ten runnable examples are included in the examples/ directory:

# Directory What it demonstrates
01 01-basic Minimal setup, <!--print::var--> directive
02 02-layout Layout inheritance, slot(), block()
03 03-modifier Custom ModifierInterface implementation
04 04-vue-sfc Vue-style SFC: template/script/style sections
05 05-php-logic PHP control structures (if, foreach, for)
06 06-css-file-cache Custom FileModifierInterface, CSS to disk
07 07-auto-load Automatic dependency discovery
08 08-content-blocks Dynamic component rendering, deferred inject
09 09-file-modifiers Built-in CssFileModifier + JsFileModifier
10 10-preview Preview mode with controls, breakpoints, and compositions
11 11-clear-cache clearCache() method and browser GET-param trigger
12 12-conditional-slot hasSlot() + <!--hasSlot::name--> / <!--/hasSlot--> conditional wrapping

Each example contains a run.php entry point and a components/ directory. Run with PHP's built-in server or include run.php directly.

API Reference

Constructor

new Template(
    string  $path,           // components root directory
    string  $cache,          // writable cache directory
    ?string $public = null,  // public directory (required for asset())
    string  $url    = '/',   // public URL prefix (used by asset())
)

Methods

Method Returns Description
render(string $component, array $data = []) string Render component to HTML string. Auto-loads if needed.
load(string $component) void Pre-warm cache and register sections. Optional before render().
hasSlot(string $name) bool Return true when the named slot was filled by a child component. Pure array check — never executes section files.
slot(string $name, string $default = '') string Return single named section content.
block(string $name, ?string $modifier = null) string Return placeholder resolved after render completes.
blockFiles(string $name, ?string $modifier = null) string Like block() but passes file paths to FileModifierInterface.
inject(string $html) string Resolve block placeholders in an HTML string manually.
e(mixed $value) string HTML-escape a value. Safe on null.
set(string $key, mixed $value) void Set a template variable.
get(string $key, mixed $default = null) mixed Read a template variable.
asset(string $src, ?string $dest = null) string Copy asset to public dir and return versioned URL.
addModifier(string $key, ModifierInterface|FileModifierInterface $m) static Register a modifier under a key.
addPreviewMode(string $key, PreviewModeInterface $mode) static Register a custom preview renderer (e.g. 'react').
clearCache() void Delete all *.cache.php files from the cache directory.
getClearCacheParam() / setClearCacheParam(string $p) string / static GET param name that triggers auto-clear (default: __demeve_clear_cache).
getClearCacheValue() / setClearCacheValue(string $v) string / static GET param value that triggers auto-clear (default: clear_cache).
errors() string[] Return collected error messages (missing components, bad loads).
preview(string|null $component = null) never Launch interactive preview browser and exit. Pass component name to open directly.

Inside component files, $this refers to the Template instance.

Notes

This project is provided as-is. There is no public roadmap, issue tracker, or contribution process at this time. Feel free to fork and adapt it to your needs.

License

MIT © Diego Mont