thelia / cms-module
All-in-one CMS for Thelia 3: page tree, visual page builder, menus, forms and media.
Requires
- php: >=8.3
- openstudio/page-builder-bundle: ^0.2.1
- symfony/html-sanitizer: ^7.4
- thelia/installer: ~1.6
- thelia/thelia-library-module: dev-twig
Suggests
- thelia/google-tag-manager-module: Sends CMS page views to the data layer.
- thelia/rewrite-url-module: Back-office management of redirections and a log of 404s.
- thelia/seone-module: Publishes the SEO metadata, canonical and hreflang tags of CMS pages.
- thelia/tnt-search-module: Indexes CMS pages for the TntSearch front-office search.
This package is auto-updated.
Last update: 2026-08-08 04:09:43 UTC
README
A CMS for Thelia 3: a tree of pages, edited in a visual page builder and published as plain HTML and CSS. A published page ships no builder JavaScript.
Alpha. Everything in Scope works and is used on a real site. The database schema has been stable since 0.6.0, and the back-office screens are where changes are still expected before 1.0.
Compatibility
- Thelia 3.0
- PHP 8.3+
- MySQL 5.6+ or MariaDB 10.0.5+, because the front-office search uses a native FULLTEXT index
- URL rewriting switched on: every CMS page is served through a rewritten URL, and the module refuses to activate without it
Installation
The released versions are alpha, which Composer will not pick on its own, so ask for them:
composer require thelia/cms-module:^1.0@alpha php Thelia module:activate TheliaCMS php Thelia cache:clear
dev-main works too, and is what to use to follow the branch.
Composer pulls in the page builder bundle and TheliaLibrary, and registers the bundle through Symfony Flex.
Activation creates the tables, seeds the ACL resources and generates the page URLs. It also seeds four unpublished legal pages (legal notice, privacy policy, cookies, accessibility statement) as placeholders in every active language. Nothing is served on the front until you publish them, and publishing is refused while a page still holds the sample text, from the back office and from a command alike: the instructions the module wrote are not a legal notice.
Deactivating the module removes the rewritten URLs it owns, so the site answers 404 rather than 500; reactivating puts them back.
Addresses
A page carries one slug per language, which is the last segment of its address.
The segments of its ancestors are prefixed automatically, so a page called
"Conseil et accompagnement" filed under "Nos services" answers on
/nos-services/conseil-et-accompagnement. The field on the edit screen holds
that last segment. Leave it empty and it is derived from the title.
Each slug is stored on the page itself, next to its title. The addresses also
live in the core rewriting_url table, which the module has to clear when it is
switched off, so that copy is what puts a site back where it was: a slug
somebody shortened by hand cannot be derived from a title, and deriving it
anyway renames the page and leaves no redirection behind. The same copy is read
when a page comes back from the bin.
Renaming a page keeps its previous address as a 301. Those redirections are not restored by a deactivation and reactivation cycle: what a page stores is the address it answers on, not the list of the ones it used to answer on.
Reserved first segments (admin, api, assets, recherche, search,
sitemap, robots.txt and a few more) are refused as slugs. The rewriting
router runs before the Symfony routes, so a page slugged admin would shadow
the back office.
Moving a page
Moving a page in the tree takes the addresses of everything under it. A descendant is addressed from the addresses of its ancestors, so a page that changes parent changes the address of its children, of their children, and so on to the bottom of the branch. Each of them keeps the slug it already had, moves under the new path in every language it answers in, and leaves its former address behind as a 301 towards the new one. Renaming a page does the same to its descendants.
The branch is re-addressed inside the transaction of the save. A subtree half moved is a site where some pages answer and others do not, which is worse than either outcome. Moving a page that sits near the root of a large site therefore rewrites every address below it, at a few queries per page and per language, so that save is not instant on a site of several hundred pages. Pages in the bin stay out of it, and a tree where a page has ended up under one of its own descendants is walked once rather than followed forever.
Trailing slashes
An address that differs from a real one only by a trailing slash answers 301
towards the form without it, so /mentions-legales/ reaches
/mentions-legales. Symfony does that for its own routes; the rewriting router
does not, and a site taken over from WordPress, Drupal or Prestashop arrives with
the slash on every indexed address and every inbound link.
The redirection is permanent, keeps the host the visitor reached, and passes the query string on untouched. Several slashes collapse in one hop, and the address redirected to never carries one, so there is no chain and no loop. A POST is left with the 404 it asked for, because a browser turns a redirected POST into a GET and drops the body.
Nothing is redirected unless the address without the slash answers. For a page of
this module that means published, visible and inside its publication window. For
an address belonging to another view (a content, a folder, a product) the module
checks that the active theme renders that view at all, and takes the row in
rewriting_url as the site saying the address exists. Whether one particular
product is currently online is that module's business, so an address of that kind
can still redirect to a 404.
The root, the back office and the API are never touched. The first segment of the
path is what is compared, not a prefix, so a page addressed
/administration-des-ventes is not mistaken for the back office.
An address the rewriting table holds with its slash is left to the router, which already answers on it, so an old address a takeover recorded in both forms keeps its single hop.
The decision is taken on the request, above the router, and not on the 404 that
would follow. Waiting for the 404 leaves out every address another route answers
for: with allow_slash_ended_uri on in the store configuration, the Thelia
request hides the trailing slash from the Symfony routes, so /contact/ falls
through the rewriting router into the /{_view} catch-all of the Front module and
renders the contact template of the theme with a 200, while the page answering on
/contact is never reached. Every single segment address whose name matches a
template of the theme is in that state.
An address without a trailing slash leaves on two string tests before any query, so a site that has no use for any of this pays nothing.
Finding a page
CMS > Pages shows the tree one level at a time. The pages at the root of the site are listed; the ones holding other pages carry the number they hold and open on a chevron. What you opened travels in the address, so a bookmark or a back button puts you where you were, and the buttons on a row bring you back to the same view. A site of fewer than forty pages opens whole, because folding buys it nothing.
Above the table, Find a page looks for a word in the title of the language being edited. The publication state and the visibility narrow it further. Asking for anything turns the tree into a list of results, fifty to a screen, and each row names the pages it sits under: indentation with the parents missing draws a tree that is not on screen. The arrows that reorder a page only appear on the tree, since a position moved from a filtered list is a position among pages you cannot see. The bin has the same search box.
The screen works without a mouse. Branches open on links, moves are buttons, and re-parenting a page is the Parent field of its edit form. It also works with JavaScript switched off, apart from the confirmation before a page goes to the bin.
Reading it costs the same on a site of six hundred pages as on a site of seven:
the state, the address and the number of pages underneath are read for the whole
screen at once, never per row. PageListingCostTest measures the same read on two
trees of different sizes and fails if the two numbers differ.
Editing a page
A page carries a title, a slug, a parent, a layout, a publication window and its
SEO metadata, all per language. Content is edited on its own full-screen route,
/admin/cms/pages/{id}/builder:
- drafts autosave every 30 seconds, and leaving with unsaved work asks first;
- Preview the draft gives a signed link, valid 72 hours, that a client
without a back-office account can open. It is
noindexand never cached; - publishing runs the content through a server-side sanitizer, rewrites the images and extracts the text for the search index;
- the heading structure is checked at publication (see
heading_check_mode).
Each language holds its own layout and its own text: the French and English versions of a page are two independent canvases.
A deleted page goes to the bin instead of being removed. CMS > Pages > Bin lists what was deleted, with the date and the time each page has left, and puts any of them back. A page comes back with whatever was nested under it; a page whose own parent is still in the bin waits for that parent.
The bin empties itself after 30 days, which trash_retention_days changes. A
page that goes takes everything with it: its content in every language, its
revisions, its search entry and its addresses. Set the value to 0 and nothing is
deleted until somebody asks for it. The clean-up runs from maintenance:purge,
the command a Thelia site already schedules, and from
thelia_cms:pages:purge-trash for a run on demand. That second command takes
--dry-run, which lists what would go and deletes nothing.
Publishing from the command line
thelia_cms:publish puts drafts online through the same pipeline as the button
in the back office: sanitizer, responsive images, search index, revision. Writing
the published column from a script skips all four and says nothing about it.
php Thelia thelia_cms:publish --page 12 --page 13 php Thelia thelia_cms:publish --all --dry-run
--all means every draft of every page that is not in the bin, which is more
than it sounds like: a page somebody is halfway through rewriting goes online in
the state their last save left it in. --dry-run lists the pairs of page and
language it would publish and runs the same refusals as a real run, so the count
it announces is the count a real run reaches.
Two drafts are refused wherever the publication comes from, the button included: one that would show nothing, and one still holding the sample text of a seeded legal page. Write your own text and the refusal goes away. There is no flag to remember and no way to publish the sample text by accident.
Pages online with the example text on them
That refusal came after the first sites did, so a site can already have those pages live. The page list and the dashboard block name them, each as a link to the page and the language that needs writing. The warning goes away on its own once the last of them is written or taken offline; there is nothing to dismiss.
Emoji and other four-byte characters
An emoji typed in a title or in a page is stored as a numeric character
reference, 📷, and read back as the character. Nothing is lost and every
screen shows what was typed.
The reason is worth knowing if you write a module of your own against the same
database. Thelia opens its connection with SET NAMES 'UTF8', which MariaDB and
MySQL read as utf8mb3, three bytes per character. An emoji needs four, and the
character set of the connection is settled before the one of the column, so a
column declared utf8mb4 does not help: the statement comes back as
Incorrect string value. This module writes those characters out itself rather
than changing a connection the whole site shares.
Two consequences to know about. A stylesheet written in the builder keeps the
escape a stylesheet understands, \01f4f7, which is what it needs to render and
what the CSS panel shows. And the front-office search does not match a query made
only of emoji, since the search index holds the written-out form.
Blocks
The editor panel holds three families.
Page blocks are the ten to start a page from: hero, text and image, call to
action, quote, testimonials, key figures, logos, gallery, questions and answers,
and a section to group other blocks. They drop semantic markup carrying cms-*
class names and no styling of their own, so a theme decides what they look like
and a page written today still looks right after the theme is reworked.
Reusable blocks (CMS > Blocks) are written once and placed on as many pages as you like: the banner that appears on twenty pages is edited in one place. Pages hold a reference rather than a copy, so publishing the block updates every page showing it. A block still used by a page cannot be deleted, and the settings screen lists where it appears.
Live content is rendered by the server on every visit rather than stored in the page: the latest news, a menu, a reusable block, and the three click-to-load embeds below. A news list written six months ago is still today's news.
Embeds that load nothing until they are asked to
A YouTube iframe dropped in a page calls Google on every visit, from every visitor, before anyone has agreed to anything. The video, map and social blocks render a poster or a card, a button, and a sentence saying which company is about to receive something. The player, the map or the post is fetched when the button is pressed, and not one second earlier. Without JavaScript the button is still a link to the platform.
Videos come from YouTube, Vimeo or Dailymotion and are addressed by identifier, never by a URL typed into an iframe. Maps come from OpenStreetMap. The poster of a video is served by your own media library.
Adding a block of your own, static or dynamic, takes one PHP class: docs/creating-a-block.md.
Media
CMS > Media stores images in TheliaLibrary. Alternative text is
mandatory in every language unless the image is marked decorative, which
publishes it with an empty alt. An empty attribute on its own cannot say
whether an image was described or simply forgotten, so the choice is recorded.
Each image shows its dimensions, weight, format and the pages using it. One still in use cannot be deleted.
Uploads accept JPEG, PNG and WebP. SVG is refused: it is a document that can carry script.
At publication every image becomes a <picture> with a WebP alternative, a
srcset bounded by the file's real width, explicit width and height, and
lazy loading on everything but the first image.
Menus
Menus live under CMS > Menus and a theme calls them by code. main and
footer exist from the first activation.
An entry points at a CMS page, a content, a folder, a web address, or at nothing at all. An entry with no target is a label on its own, which is how a group heading is made. Its own label is optional: left empty, the title of the target is shown, in the language being read.
The tree goes three levels deep. Entries are reordered with the move buttons or by dragging a row, and nested either by picking a parent in the form or by dropping a row onto the right-hand third of another one. Dragging is a shortcut. It cannot be done with a keyboard, so it is never the only way to reorder a menu.
An entry whose target has been deleted, taken offline, or left unpublished in the language being read is left out of the menu in that language, and listed in the back office with the reason. A heading that still has usable children stays as a heading, rather than let its children move up a level.
Forms
Forms live under CMS > Forms. A form has a code, the way a menu does, and a page places it with the Form block from the editor. The page stores that reference and nothing more: the fields, the wording and the recipients are read when the page is served, so adding a field never means republishing the pages the form is on.
A field is one of nine kinds: a single line of text, an email address, several lines, a drop-down list, a tick box, one choice among several, a phone number, a date, or an agreement to be contacted. Labels, help texts and the answers a list offers are written per language. A field with no label in a language is left out of the form in that language, and the back office says so: an input nobody can name cannot be filled in, and a screen reader announces nothing for it.
The agreement field is never ticked in advance, and the answer stores the exact sentence the visitor read along with the moment they agreed. If the agreement is ever questioned, that is what has to be produced. In France, since 11 August 2026, it is also what makes a phone number usable for a commercial call.
Recipients are set here and nowhere else. A form that took its recipient from the page, or worse from the request, would be a mail relay with a nice interface.
What stops the robots
Three checks, none of which asks anything of the visitor:
- a field only a robot fills in;
- a signed record of when the form was served, so a message sent in under three seconds, or with a stamp this site never issued, goes nowhere;
- a cap on how many messages one sender may get through, kept in the core
form_firewalltable undercms_form_<code>and honouring the existingform_firewall_attemptsandform_firewall_time_to_waitsettings. Only accepted messages are counted: mistyping an address six times on a long form should not lock somebody out for an hour.
There is no captcha. A captcha sends the visitor to a third party and gets in the way of anyone using assistive technology, and on the volume a showcase site sees it catches less than the three checks above.
Answers
CMS > Forms > Answers lists what a form received, searchable by email address. From there an answer is exported as CSV or as JSON, or deleted. That is what answering a request to see or to erase personal data comes down to, and it takes a couple of minutes rather than an afternoon of SQL. The CSV keeps a column for a question that has since been removed from the form, and defuses any cell a spreadsheet would run as a formula.
Each form states how long its answers are kept, 365 days by default. They are
deleted on their own by thelia_cms:forms:purge, and by maintenance:purge
along with the carts and the admin logs. Hooking onto the command a Thelia site
already schedules is deliberate: a retention rule needing a cron entry of its
own is one that half the sites never run.
The address a message came from is never stored: what is kept is a keyed hash of it, which recognises the same sender twice without recording who visited.
A sent form is pushed to the data layer as generate_lead, carrying the code of
the form and nothing else, and only when the person agreed to be contacted.
Dashboard
On a showcase site, the back-office home page gains a block of its own, above the shop charts: those count orders and turnover, which on a site with no shop is a screen of zeros somebody has to scroll past.
It shows how many messages the forms received over the last thirty days, how many pages are online and how many are still drafts, how much of the site exists in each active language, the five pages changed last, and whether anything is measuring the site at all. Nothing here re-implements analytics: the last line links to the scripts screen and says how many are running.
Above all of that, when there is something to say: the pages that are online with the example text of the seeded legal pages still on them. The same warning is on the page list, which is the screen somebody opens to go and write them.
The block is only added in showcase mode, and only for somebody allowed to see the pages.
Shared cache
Every rendered page carries a Cache-Tag header (and Surrogate-Key, which is
the same list under the name Fastly and Cloudflare read) naming what went into
it: the page itself, the menus it draws and the site settings. Publishing a page
drops that page from the cache, saving a menu drops the pages that draw it, and
editing a reusable block drops the pages it appears on, found by looking through
the published HTML rather than from a table somebody has to remember to write
to.
Purging is done by whatever sits in front of the site. The module ships no
implementation, because what a purge means depends on the proxy and guessing
wrong is worse than doing nothing. A project implements CachePurgerInterface
and the tag picks it up. A purger that fails is logged and skipped: not reaching
a CDN must not turn publishing a page into an error the editor sees.
Whether a page may be shared at all is decided once the response is finished,
not while it is being built, because the answer depends on the cookies on it. A
page is only ever marked public for a plain GET, from a visitor who arrived
without a session, with a 200 answer that sets no cookie of its own.
That last condition is the one that matters in practice: Thelia opens a
session on every front-office request and sets PHPSESSID on the way out, so
out of the box no page is marked public and http_cache_ttl changes nothing.
Making it work is a matter of configuring the proxy to drop the session cookie
on the addresses it is allowed to cache, which is the same thing every Varnish
setup in front of Thelia already does. The tags are written either way, so the
invalidation side works from the start.
docs/shared-cache.md has the VCL that does it, the same thing for Fastly and
Cloudflare, and a complete purger to copy into a project.
Sitemap
A theme opens its sitemap to modules by calling theme_hook('sitemap.urls')
inside its <urlset>, passing the language and the context it was asked for.
The module answers there with its published pages, so the pages are part of the
sitemap the site already had rather than of a second one nobody submits.
Each entry carries lastmod set to the publication date, never to the update
date of the row: a row is touched by things a reader never sees, and a sitemap
claiming every page changed last night is one a crawler learns to distrust.
Pages that exist in several languages carry an xhtml:link to each of them,
themselves included, since a one-way hreflang is ignored. There is no
changefreq and no priority, which no search engine has read for years.
Only pages a visitor can reach are listed. The bin, the drafts, a page waiting
for its publication date, a page past its unpublication date and a page marked
noindex in a given language are all left out.
Themes deriving from thelia/flexy-cms already have the hook. On any other
theme, adding it is one line, and the xmlns:xhtml namespace on the <urlset>
element.
Search
The module answers on /recherche and on /search with a results page. Both
paths are on the reserved list, so no page can be given a slug that would shadow
them, and the language of the page is the one the visitor is reading the site
in rather than one the address imposes.
The query runs against the plain text extracted when a page was published, never
against the HTML: a full-text index over markup matches tag names and ranks a
page by how much of it there is. Only pages a visitor may reach are searched.
The bin, the drafts, a page waiting for its publication date and a page marked
noindex are filtered in SQL rather than dropped from the results afterwards,
which is what keeps a page of ten results from showing three.
What the visitor typed is stripped of everything MySQL boolean mode reads as an operator. Boolean mode has no escape character, so a search for "C++" would otherwise be a syntax error rather than a search. Every word is required and the last one is completed as a prefix, so "access" already finds the accessibility statement.
The results page answers X-Robots-Tag: noindex, follow. It exists in as many
versions as there are queries, which is exactly what search engines ask sites
not to have indexed.
A theme takes over the layout by shipping cms-search.html.twig at its root.
Until it does, the module renders its own, in cms-* class names with no
styling of its own.
On a site that also runs TntSearch, the module registers a CmsPageIndex over
the same rows. Without TntSearch nothing is registered and the built-in search
answers alone.
Scripts and measurement
CMS > Scripts and measurement holds the third-party snippets of the site: the measurement tags, the chat widget, whatever the agency was asked to add. They live here rather than in the theme, so they are the same on every page and removing one is a click.
Each snippet says where it goes and which vendor of your consent platform it
waits for. A snippet naming a vendor is written into the page inside a
<template>, which the browser reads but does not run: no script executes, no
image is fetched, no iframe connects. It comes out once the visitor has agreed
to that vendor. Marking only the script tags as text/plain would leave the
tracking pixel beside them free to fire, and that is the tag that needed consent
most.
A snippet with no vendor loads for everyone, straight away. That amounts to saying the site cannot run without it, so the screen labels it that way.
The consent layer is Axeptio, set up on the same screen with your project
identifier. It goes at the very top of the head, before anything else, and
Google Consent Mode is told first that nothing is allowed: ad_storage,
ad_user_data, ad_personalization and analytics_storage all denied, with a
short wait so a returning visitor's earlier answer arrives before the tags do.
Since 15 June 2026 ad_storage decides whether a Google Ads conversion is
counted at all, so a site that never emits these defaults and never updates them
measures nothing rather than measuring without consent. What each vendor may
turn on is a JSON map, which defaults to the two Google products.
The screen sits behind the custom-code permission rather than the settings one: whoever can paste a script tag onto every page can do anything a visitor's browser can do.
With no consent platform set up, a snippet that waits for a vendor never loads. The screen says so rather than letting you find out from the traffic.
Site icon
A theme points its <link rel="icon"> at cms_site_icon(), which serves the
file uploaded in Configuration > Store when there is one, and returns nothing
when there is not, so the theme falls back on its own. Changing the icon of a
site becomes an upload in the back office rather than a file to replace in the
theme.
Thelia keeps that file outside the public directory and reads it through an
admin-only route, so the module serves it on /site-icon, cached for a day. Only
the extensions a browser accepts as an icon are served: the file name comes from
a configuration row, and this path answers before any authentication.
Configuration
Settings live in module_config and are read through
TheliaCMS::getConfigValue().
Most of them are edited under CMS > Settings.
| Value | Default | Description |
|---|---|---|
home_page_id |
none | Page served on /. Set from the page list; its own slug then 301s to /. |
site_mode |
commerce |
vitrine closes the shop paths and puts CMS first in the back-office menu. |
404_page_id |
none | CMS page served when an address does not exist, with a 404 status. |
maintenance_active |
0 |
1 closes the site with a 503. |
maintenance_allowlist |
empty | IP addresses and CIDR ranges that keep seeing the site while it is closed. |
maintenance_page_id |
none | CMS page shown while the site is closed. |
trash_retention_days |
30 |
Days a deleted page stays in the bin before it is deleted for good. 0 keeps it until somebody deletes it by hand. |
http_cache_ttl |
0 |
Seconds a shared cache may keep a page. 0 disables it, which is the default. |
axeptio_client_id |
none | Axeptio project. Without it no banner shows, and every snippet waiting for consent stays off. |
axeptio_cookies_version |
none | Which Axeptio configuration to load, when a project has several. |
axeptio_consent_map |
the two Google products | JSON: vendor to the Consent Mode signals it grants. |
cache_ttl |
3600 |
Seconds a resolved menu is cached for. It is also dropped on every change that affects it. |
heading_check_mode |
warn |
warn reports heading problems and publishes anyway; block refuses to publish. |
builder_stylesheet |
none | Public path of the stylesheet the editor canvas loads. Defaults to the asset mapper's styles/app.css. |
builder_palette |
none | JSON array of hex colours offered in the editor, e.g. ["#111827","#ffffff"]. Defaults to a contrast-checked set. |
Showcase mode, maintenance and the 404
A showcase site answers 404 on /cart, /order and /checkout, and moves
CMS to the top of the back-office menu. Nothing else changes, and switching back
undoes it: the shop is one save away.
Saving showcase mode also creates the Editor profile: pages, menus, media, forms and news, and none of the shop, none of these settings and no free HTML. Assign it under Configuration > Administrators; its permissions are yours to change afterwards, and it is never touched again.
Maintenance answers 503 with Retry-After, which asks search engines to come
back rather than to drop the page. A 200 saying "back soon" is what gets indexed
in place of a site, and a 404 is what gets it removed. Addresses that do not
resolve answer 503 as well, since the check runs before routing. Three ways
through: the back office, the IP addresses on the allow list, and an
administrator already signed in. The page shown can be a CMS page; if it is not
published in the visitor's language, a plain page from the module is served
instead, because the theme is part of what may be under repair.
The page shown when an address does not exist is a CMS page like any other, served with the 404 status. Answering 200 would have search engines index it under every wrong address ever linked to the site.
Permissions
Six resources, seeded on activation with no access granted. Open them per profile under Configuration > Administrators.
| Resource | Covers |
|---|---|
admin.cms.page |
the page tree, the builder, publication |
admin.cms.menu |
the menus and their entries |
admin.cms.media |
the media library |
admin.cms.custom-code |
free HTML in the editor, and <iframe> in published content |
admin.cms.settings |
the settings screen: showcase mode, maintenance, the 404 page |
admin.cms.form |
reserved for the screens still to come |
Every route under /admin/cms is guarded by the resource of its section, so a
route added later cannot ship unprotected by omission.
Theme integration
The module renders a page with cmspage.html.twig from the active theme when
the theme provides one, and falls back to its own otherwise. It therefore works
on any Twig theme exposing a base.html.twig, and a theme can take the layout
over without touching the module.
Both versions emit the same hooks, which are the extension points for other
modules. Each receives the page as page.
| Hook | Type | Rendered |
|---|---|---|
cmspage.top |
front | before the content |
cmspage.content.before |
front | inside the article, before the content |
cmspage.content.after |
front | inside the article, after the content |
cmspage.bottom |
front | after the content |
Twig functions
| Function | Returns |
|---|---|
cms_menu(code, locale) |
the tree of a menu. Each entry has label, url (null for a heading), blank, children, active, in_trail. The locale defaults to the one being served. |
cms_page_alternates() |
the page being served, in each language it exists in: locale, code, title, url, current |
They return data rather than markup, because navigation markup belongs to the theme. A menu of any depth takes a dozen lines:
{% macro menu(entries) %}
<ul>
{% for entry in entries %}
<li>
{% if entry.url %}
<a href="{{ entry.url }}"{% if entry.blank %} target="_blank" rel="noopener"{% endif %}>{{ entry.label }}</a>
{% else %}
<span>{{ entry.label }}</span>
{% endif %}
{% if entry.children is not empty %}{{ _self.menu(entry.children) }}{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{{ _self.menu(cms_menu('main')) }}
Menus are cached per code, language and host, and the cache is dropped whenever a menu is saved, or a page it points at is renamed, published, unpublished or binned.
cms_page_alternates() is what a language switcher should be built on: it
answers with the current page in each language, absolute and on the right domain
when the shop runs one domain per language, and it leaves out the languages the
page is not published in. It works beyond CMS pages: on a product or a
category it follows the rewritten URL of that object in the other language, and
elsewhere it carries the current path over. A switcher built on a ?lang=
parameter alone sends the visitor back to the home page, losing the page they
were reading; the same addresses feed the hreflang tags, so the two cannot
drift apart.
When SEOne is installed, CMS pages describe themselves to it: title,
description, WebPage microdata, and a breadcrumb built from the page tree.
Their hreflang alternates come from the languages a page is published in.
Without SEOne the module runs unchanged.
Moving content between sites
Two commands write and read the content of a site as one JSON file:
php Thelia thelia_cms:export site.json php Thelia thelia_cms:import site.json [--replace] [--with-settings]
The file holds the page tree, the content of every language, the menus, the forms and their fields, the reusable blocks and the settings. It is a starter kit you build once and start the next project from, and it is a copy of the content to keep somewhere other than the database.
Three things stay behind. Form submissions, because they are what visitors wrote about themselves and a starter kit ends up on laptops. Third-party snippets, because they carry the measurement accounts of one site and importing them elsewhere would send that site's traffic into them. Revisions, because they are the history of one site rather than its content.
Images travel as file names. Upload them to the media library of the other site first, under the same names, and the import points the content at them; whatever is missing is named in the report rather than left as a silent hole.
An import leaves alone whatever is already there: a page at the same address, a
menu or a form carrying the same code. It counts them and says so. --replace
overwrites them instead. Settings are only applied when asked for, so importing
a starter kit never switches a running site into showcase mode by surprise. The
whole thing runs in one transaction, so a file that turns out to be broken
halfway through leaves the site as it was.
Templates
The Templates screen keeps a page aside as a starting point for others: pick a page, name the template, and it appears in the list for anybody who writes pages. Starting from one asks for a title and where the page goes, then opens the editor on a hidden draft, so a template can be tried without anything showing up on the site.
A template stores the export document of the page it was made from, so what the export command writes and what a template holds are the same thing, and a template built on one site is a file that can be handed to another.
Scope
Working today: the page tree with its bin and duplication, the visual builder with drafts, revisions, autosave and shared previews, the block catalogue, reusable blocks, dynamic blocks including click-to-load embeds, the publication pipeline (sanitizer, responsive images, search indexing, heading check), the media library, menus, hierarchical URLs with 301s on rename, forms and their answers, the front-office search, third-party snippets behind consent, the sitemap section, cache tags, the showcase dashboard, import and export, templates, showcase mode, maintenance, the editable 404, the ACL, and the activity log.
Tests
The unit tests need neither a database nor an installed shop, only the autoloader of the project the module sits in:
vendor/bin/phpunit -c local/modules/TheliaCMS
They cover the sanitizer against a corpus of hostile HTML and CSS, the content normalizer, the responsive image rewriting, the heading check, the signing of preview links and the slug rules.
The integration tests need a shop, and they run against its test database:
php bin/test-prepare # once: creates the `test` database and activates the modules
vendor/bin/phpunit -c local/modules/TheliaCMS/phpunit.integration.xml.dist
They cover what only exists once there are rows: the export and import round trip, the search index, the sitemap entries, the dashboard figures and the breadcrumb. Each test runs inside a transaction that is rolled back afterwards, so the content of the shop is left alone. The exception is the search: InnoDB only adds a row to a FULLTEXT index when the transaction writing it commits, so those tests commit and clean up after themselves.
Accessibility is checked separately, with axe-core driving a browser against a
running shop. Tests/Accessibility/README.md says how to run it and what the
last run found.
Page builder assets
The editor is bundled by the module and served with module_asset() on the
builder screen only. The compiled files are committed, so installing the module
needs no Node.js.
Rebuild them after changing anything under assets/src/:
cd assets npm install npm run build # or npm run watch
The build reads the editor sources from openstudio/page-builder-bundle in the
Composer vendor directory of the surrounding project, so run composer install
there first.
While developing, Thelia only republishes an asset to public/assets when the
process_assets configuration value is on; with it off, the previous build
keeps being served.
Guide for whoever runs the site
docs/guide/thelia-cms-guide-webmaster.pdf is a printable guide in French,
written for a webmaster rather than a developer: what each screen does, what the
settings change, and what is worth checking once before a site goes live. Its
source and the instructions to rebuild it are in the same directory.
Licence
LGPL-3.0-or-later. See LICENSE.