demeve / template
A PHP library to load and render view files split into named sections (output, style, script, etc.).
Requires
- php: ^8.1
Requires (Dev)
- phpunit/phpunit: ^11
README
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
.phpor.htmlfile - Layout inheritance:
<!--extends::Layout-->with named slots and accumulated blocks - Comment directives: IDE-friendly syntax that compiles to PHP calls
- CSS scoping:
__selfplaceholder replaced with the component name — no collisions - Smart caching: per-section
.cache.phpfiles, 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→.__selfbecomes.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.php → AutoloadTypography. 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: PageWithsidebar → components/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