Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/puppets-developpement-toolbox/carlo/archive/refs/heads/2.x.zipCarlo is a PHP templating engine whose content structures are described in YAML, and the WordPress plugin built
on it, developed by Puppets. A theme declares its page templates, regions and sections in a
structure.yml file; every section becomes a native block in the WordPress block editor, and is rendered by a
plain PHP template on the site.
| Path | Content |
|---|---|
src/carlo |
the engine, without WordPress: drivers, structure loading, fields, data loading, introspection |
src/templates |
generic sections and components (PHP template + YAML structure), see these mockups |
wp.php, src/wp, assets |
the WordPress plugin: driver, blocks, regions, page templates, component catalogue, editor scripts |
library.css, src/css, src/js |
front-end styles and scripts (Tailwind 4) |
demo |
standalone demo of the templates, without WordPress |
dev |
WordPress development environment (Bedrock + Lando) and browser tests, see dev/README.md |
Further reading: Carlo.md (concepts, in French) and README-AI.md (code architecture, for developers and AI agents changing the code).
Requirements
- PHP 8.1+ and WordPress 6.5+ (block API v3,
wp_is_serving_rest_request()). - A theme with a
structure.ymlfile at its root: without it, the plugin does nothing. - Composer, through which the package and its dependency (
symfony/yaml) are installed. idleberg/wordpress-vite-assetsin the site's dependencies: it loads the theme's assets in production and is not declared by the package yet.
Installation (Bedrock)
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/Puppets-Developpement-Toolbox/library.git" }
],
"require": { "puppets/carlo": "<branch or version>" }
}
The package type is wordpress-plugin: it is installed in web/app/plugins/carlo. Then:
- Activate the Carlo plugin.
- In the theme, call
carlo_bootstrap()fromindex.php: the plugin picks the template and renders the page. - Import the library styles in the theme's CSS:
@import "../../../plugins/carlo/library.css";.
The theme's assets are loaded with Vite (see Assets).
Elements
Sections (sections/…) and components (components/…) are elements. An element is identified by
type/name:variant, e.g. sections/push:pictos; the variant defaults to base (sections/text =
sections/text:base). Each element is made of files in a templates/ folder:
templates/sections/quote/
├── base.php # the template
├── base.yml # its structure: the fields (optional for an element without data)
├── base.stories.yml # examples for the component catalogue (optional)
└── dark.php # a variant: dark.yml and dark.stories.yml fall back to base.* when absent
A missing file of a variant falls back to the base file, for the template, the structure and the stories
alike. A flat file (templates/sections/quote.php) is also accepted for the base variant.
Files are looked up, first found wins, in: the child theme, the parent theme, the plugin (src/wp/templates),
then the library (src/templates). A theme overrides any element of the library by providing the same file.
Elements of another library can be registered as a namespace:
carlo_register_namespace('restaurant', $path), then @restaurant:sections/menu.
structure.yml
menus:
main: Menu principal # registered as a WordPress menu location
regions: # labels shown in the editor
header: En-tête
content: Contenu
templates: # page templates; `default` is used when none is chosen
default:
_label: Page éditoriale
content: # a region and the sections it accepts
- _blocs: [!load sections/text, !load sections/image, !load sections/quote]
_max: 6 # at most 6 sections in this region
landing:
_label: Page templatisée
header: [!load sections/image]
content: [!load sections/text, !load sections/quote]
types: # custom post types
project:
wp_args: # arguments of register_post_type()
label: Projets
supports: [title, thumbnail]
template: # its template (or `templates:` with several, `default` first)
_label: Fiche projet
content: [!load sections/text]
!load sections/textinserts the structure of an element. It is the only YAML tag: any other tag (e.g. a typo such as!components) raises an error naming the file.- A region proposes every section it lists, in any order and number (up to
_max). - The template of a content is chosen when it is created (« Pages > Ajouter : Page templatisée ») and cannot
change afterwards. The posts page uses the
archivetemplate.
Fields
A field is declared in the element's .yml, by its type alone or with options:
titre: text_short
content:
_type: wysiwyg
_label: Contenu
_help: Texte affiché sous le titre.
quote: !load components/quote # a component: a group of fields with its own template
items:
_type: repeater
_min: 1 # or _times for an exact number
_max: 6
_add_button: Ajouter une carte
_repeat:
title: text_short
image: image
pages:
_type: reference
_ref_type: [page, project]
_max: 3
style:
_type: choices
_choices: { light: Clair, dark: Sombre }
| Type | Editor | Value in the template |
|---|---|---|
text_short |
typed in the block | string |
text |
typed in the block | string, line breaks as <br /> |
wysiwyg |
typed in the block (TinyMCE dialog) | HTML, paragraphs added (wpautop) |
url, number |
Settings panel | string, number |
boolean |
Settings panel | bool |
image |
Settings panel (media library) | attachment ID, for carlo_img() |
choices |
Settings panel (_multi for several) |
the chosen key(s) |
reference |
Settings panel (search, ordered list) | published WP_Post objects |
group |
its fields, in the block or the panel | array, with the component's _id |
repeater |
Settings panel, items added and ordered | list of arrays |
A text field used inside an HTML attribute (e.g. id="<?= carlo_get('ancre') ?>") is typed in the
Settings panel instead of the block. select is a deprecated name of choices; image with _multi is a gallery. Other types (gallery,
color, date, datetime, embed, file, link, taxonomy) are stored but not editable yet: the editor
shows a notice in their place.
Every declared field reaches the template, with a default value when it is empty ('', false for a
boolean, [] for a group, repeater, gallery or reference): templates do not need isset().
Reading data elsewhere: _expose and carlo_get_exposed()
A field is typed in its section, but a template may need it elsewhere (the opening hours of a restaurant in
a card, in the header…). Name it with _expose, on a section (all its data) or on a field:
# templates/sections/opening_hours/base.yml
_label: Horaires d'ouverture
_expose: opening_hours # the whole section
hours:
_type: repeater
_expose: hours_list # or one field
carlo_get_exposed('opening_hours'); // the displayed content
carlo_get_exposed('opening_hours', $post); // another one: an ID or a WP_Post
The value is loaded like in the section's own template (same formatting), with default values when the section is not in the content. Rules, checked when the structure is read:
- a name designates one piece of data, the same name is accepted only for the variants of one section
(
hero:sliderandhero:pic); _exposeis not allowed on a field inside a repeater (which item?);- a section present several times in a content: the first one is read.
Fields of a content type: meta:
Data that belongs to no section (an identifier used for API calls…) is declared on the type, with the same syntax as a section's fields:
types:
restaurant:
wp_args: { label: Restaurants, supports: [title, thumbnail] }
template: { _label: Fiche restaurant, content: [!load sections/text] }
meta:
_label: Informations du restaurant # title of the panel (default: Informations)
google_place_id:
_type: text_short
_label: Identifiant Google My Business
Each field is a post meta (register_post_meta, exposed to the REST API: never put a secret in it), edited
in a panel of the document sidebar of the block editor and read with carlo_get_exposed('google_place_id'),
like any exposed data. Supported types: text_short, text, wysiwyg, number, boolean, choices
(single), url, image and reference. The key is the exposed name: it must be unique among all exposed names.
A reference links a content to others, e.g. a restaurant to its brand. It is stored as a list of post IDs
(limited to _max) and read, like in a section, as published WP_Post objects. The referenced types need
show_in_rest (the editor searches them through the REST API):
types:
brand:
wp_args: { label: Marques, supports: [title], public: false, show_ui: true }
meta:
brand_logo: image
restaurant:
meta:
brand:
_type: reference
_label: Marque
_ref_type: brand
_max: 1
$brand = carlo_get_exposed('brand', $restaurant)[0] ?? null; // WP_Post or null
The block editor
- Each region of the content's template is a locked block, which only accepts the sections it lists, up to
its
_max(a counter shows it). - Each section is a block (
carlo/sections-text,carlo/sections-push-pictos…). Its preview is the real template rendered by the server, with the theme's CSS; text fields are typed directly in it, the other fields are in the Settings panel. Changestructure.yml, reload the editor: the blocks follow. - A section left empty is not rendered on the site; the editor shows a message in its place.
Templates
<?= carlo_get('titre') ?> // a value; 'quote.author' for a nested one
<?php carlo_component('quote') ?> // a group field, rendered by its own template
<?php carlo_render('components/cta', carlo_get('cta')) ?>
<?= carlo_img('image', '630x394') ?> // responsive image (see below)
<?php carlo_menu('main') ?> // renders templates/menus/main.php with `items`
Values arrive formatted (see the table above). Data that does not come from a block, such as a query in a
custom template, is loaded with carlo_load_data($id, $values): it is formatted like block data and becomes
readable with carlo_get().
carlo_bootstrap() picks the template: `templates/