Contents Plugin
A content management plugin that enables developers to create and manage websites, news sections, and blogs. This plugin provides a flexible and extensible way to handle different types of content with support for various content sources and user permissions.
Features
- Multiple content types support:
- Database content (
db) - File-based content (
file) - PHP dynamic pages (
php) - Plugin-based content (
plugin) - Built-in search functionality
- Pagination support
- Tag management system
- User role-based access control
- SEO metadata management
- Hreflang support for multi-language pages
- Configurable canonical URLs
- Breadcrumb support
- Template customization
- Content categorization by sections
Installation
- Place the plugin in your project's
Plugins/Contentsdirectory - Configure the base path for content files (optional)
Configuration
Default Content Path
By default, the plugin looks for content files in:
[PROJECT_ROOT]/static/contents/
Custom Content Path
To override the default content path, define the following constant in your application:
define('PLUGIN_CONTENTS_BASE_PATH', '/your/custom/path/to/contents/')
Usage
Content Types
- Database Content (
db) - Store content directly in the database
-
Supports HTML content with automatic escaping
-
File Content (
file) - Serve content from static files
- Files should be placed in the configured content directory
-
Supports HTML files
-
PHP Dynamic Pages (
php) - Create dynamic content using PHP
- Supports custom templates and logic
-
Files should be placed in the configured content directory
-
Plugin Content (
plugin) - Integrate with other plugins
- Custom plugin methods can be called to generate content
Template Integration
The plugin provides several template blocks for easy integration:
fetchLatest()- Display latest content itemsgetTagsByIdents()- Retrieve active tags for specific idents in custom page logicfetchTagsBlock()- Display content tagsfetchSearchBlock()- Add search functionalityfetchContents()- Display content itemsfetchPaginationBlock()- Add pagination controls
URL Structure
Content pages are accessible via the following URL pattern:
/content/{content_identifier}/
Management Interface
The plugin includes a management interface accessible at:
/manage/contents/
For tag management:
/manage/contents/tags/
Tag rows support status = active|disabled.
activetags are available to public helpers such asgetTagsByIdents(),fetchTagsBlock(),fetchTagContentsBlock(), andgetTagByIdent().disabledtags remain editable in the admin DGS but are hidden from public tag helpers and tag-filtered frontend listings.
Admin Panel Setup Notes
- The management DGS for
contentsandcontents_tagsdepends on thecontents_managepermission section from the plugin install SQL. - The URLs above also require matching rows in
festi_section_actions,festi_sections_user_types_permission, and the admin menu tree. - If a project database was initialized from an older or partial dump and
those rows are missing, both the admin panel and
festi-dgs-execcan fail with a permission error even for an admin token.
CLI Usage
Run festi-dgs-exec from the project root inside Docker and pass an admin
access_token.
Replace [YOUR_CONTAINER] with your application container or Docker Compose
service name.
List existing tags:
docker compose exec -T [YOUR_CONTAINER] sh -lc '
cd /var/www/html/src/site &&
./vendor/bin/festi-dgs-exec \
--plugin Contents \
--dgs contents_tags \
--action list \
--path /var/www/html/src/site \
--access-token <admin-token>
'
Create a tag:
docker compose exec -T [YOUR_CONTAINER] sh -lc '
cd /var/www/html/src/site &&
./vendor/bin/festi-dgs-exec \
--plugin Contents \
--dgs contents_tags \
--action insert \
--values "{\"caption\":\"Blog\",\"ident\":\"blog\",\"url\":\"blog\",\"status\":\"active\"}" \
--path /var/www/html/src/site \
--access-token <admin-token>
'
Create a database article linked to a tag:
docker compose exec -T [YOUR_CONTAINER] sh -lc '
cd /var/www/html/src/site &&
./vendor/bin/festi-dgs-exec \
--plugin Contents \
--dgs contents \
--action insert \
--values "{\"caption\":\"CLI Test Blog Article 01\",\"url\":\"/cli-test-blog-article-01/\",\"type\":\"db\",\"description\":\"Short excerpt\",\"content\":\"<p>Article body</p>\",\"meta_title\":\"CLI Test Blog Article 01\",\"meta_description\":\"Short excerpt\",\"m2m_contents2contents_tags\":[<tag-id>]}" \
--path /var/www/html/src/site \
--access-token <admin-token>
'
Notes:
contents_tagsnow has a requiredstatusfield. Useactivefor visible frontend tags ordisabledto keep a tag hidden from public helpers.- The tag relation does not use the DGS field name
tagsin the CLI payload. It uses the request key formatm2m_<linkTable>, which for this plugin ism2m_contents2contents_tags. - For multiple tags, pass a JSON array of tag ids, for example
"m2m_contents2contents_tags":[3,4]. - CLI inserts can set only the fields exposed in
tblDefs/contents.xml.
Sitemap URL Management via CLI
List existing sitemap URLs:
docker compose exec -T [YOUR_CONTAINER] sh -lc '
cd /var/www/html/src/site &&
./vendor/bin/festi-dgs-exec \
--plugin Contents \
--dgs sitemap_urls \
--action list \
--path /var/www/html/src/site \
--access-token <admin-token>
'
Add a custom URL:
docker compose exec -T [YOUR_CONTAINER] sh -lc '
cd /var/www/html/src/site &&
./vendor/bin/festi-dgs-exec \
--plugin Contents \
--dgs sitemap_urls \
--action insert \
--values "{\"url\":\"/en/about/\",\"changefreq\":\"monthly\",\"priority\":\"0.8\",\"status\":\"active\"}" \
--path /var/www/html/src/site \
--access-token <admin-token>
'
Update an existing URL (replace <id> with the row id from the list output):
docker compose exec -T [YOUR_CONTAINER] sh -lc '
cd /var/www/html/src/site &&
./vendor/bin/festi-dgs-exec \
--plugin Contents \
--dgs sitemap_urls \
--action edit \
--id <id> \
--values "{\"changefreq\":\"weekly\",\"priority\":\"0.9\"}" \
--path /var/www/html/src/site \
--access-token <admin-token>
'
Remove a URL:
docker compose exec -T [YOUR_CONTAINER] sh -lc '
cd /var/www/html/src/site &&
./vendor/bin/festi-dgs-exec \
--plugin Contents \
--dgs sitemap_urls \
--action remove \
--id <id> \
--path /var/www/html/src/site \
--access-token <admin-token>
'
Notes:
changefreqaccepted values:always,hourly,daily,weekly,monthly,yearly,never.priorityis a decimal from0.0to1.0(step0.1). Default is0.5.lastmodis optional. Format:YYYY-MM-DD.statusmust beactiveordisabled. Onlyactiverows appear insitemap.xml.- The web UI is available at
/manage/sitemap/.
Hreflang (Multi-Language Pages)
Pages that exist in multiple languages can be linked together so search engines understand they are language variants of the same content.
- Set the
langfield on each page to its ISO 639-1 code (e.g.uk,en). - Set the same
group_identvalue on all language variants of a page. - The plugin automatically outputs
<link rel="alternate" hreflang="...">tags when a page has agroup_ident,lang, and at least one other variant. - The default language (used for
x-default) is read from thedefault_langsetting, falling back to the corelangoption.
Example output in <head>:
<link rel="alternate" hreflang="uk" href="https://example.com/about/" />
<link rel="alternate" hreflang="en" href="https://example.com/en/about/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about/" />
Canonical URL
Each content page can have an explicit canonical_url. When set, it is used
as-is in the SEO data. When left empty (NULL), the plugin defaults to
https://HOST + page URL.
Upgrade Existing Installs
Use the new plugin update SQL that matches your database engine:
install/update.pgsql.sqlinstall/update.mysql.sqlinstall/update.mssql.sql
These scripts add contents_tags.status, backfill existing rows to active,
add contents.group_ident and contents.canonical_url columns for hreflang
support, and keep fresh installs aligned with the updated DGS schema.
Admin Panel Workflow
- Open
/manage/contents/tags/and create the tag first. - Open
/manage/contents/and create a new page. - For a blog article, choose
Source Type = Database. - Fill
Caption,Url,Description,Content, SEO fields, andTags. - Save the page and verify it opens by its
Url.
Asset Bundling
The Contents plugin ships a small CSS/JS asset bundler that consolidates
30+ individual <link> / <script> requests a typical Festi page makes
into a small set of content-hashed bundles. Each bundle declares where
its tags get emitted (head or body) and may opt into size-bounded
splitting so a single jumbo file becomes several smaller chunks that
parallel-fetch better. The bundler is opt-in and CLI-driven — themes
that don't want it pay zero runtime cost.
Breaking changes since !48
The position-routing + size-splitting work in !49 deliberately drops
back-compat with the shapes that landed in the initial bundler MR
(!48). Themes upgrading from !48 need three coordinated edits — the
old config and the old manifest reader will not work after this point:
asset-bundles.phpconfig — flat list rejected. Each bundle entry must now be an associative array (['files' => [...], …]), not a plain list of paths. The old'vendor_css' => ['a.css', 'b.css']shape is rejected at spec load withBundle 'vendor_css' has unknown key(s): 0, 1, 2becausearray_keys()on a list returns integer keys that aren't in the allowed set (files/position/max_size). See Theme contract below for the new shape.manifest.jsonshape — map → ordered list. The manifest'sbundlesfield is now a list of{name, ext, position, files[]}objects, not aname → basenamemap. Code doing$manifest['bundles']['vendor_css'](map access) now readsnullfrom the list and emits empty bundle URLs. See Manifest shape below.AssetManifest::lookup()removed. Templates that calledAssetManifest::lookup($themePath, 'site_css')to fetch a single bundle's basename must switch toAssetManifest::loadByPosition($themePath, 'head' | 'body'), which returns an ordered list of bundle entries for the requested position. There is no single-bundle accessor on the new API — templates iterate over the position's bundles and emit each entry'sfiles[]chunks in order.
Because of (2) and (3), the Contents submodule bump and the
host-site's template + spec migration must land in the same merge
cycle. Bumping the submodule pointer first without updating the
host's asset-bundles.php and templates ships broken bundle URLs
(empty href) and / or a fatal undefined method lookup() on every
page render.
What it does
For each bundle declared by the active theme, the bundler:
- Reads the ordered list of theme-relative file paths.
- If the bundle has a
max_size, splits the file list into ordered chunks viaAssetBundleChunker(greedy first-fit, order-preserving so JS deps and CSS cascade stay intact). - For each chunk: concatenates the files (order matters), minifies the
concatenation with
matthiasmullie/minify, and SHA-1 hashes the unminified concatenation — so unchanged source produces a stable filename across rebuilds. - Writes
<themePath>/bundles/<name>.<sha8>.min.<ext>for single-chunk bundles, or<themePath>/bundles/<name>.<N>.<sha8>.min.<ext>(1-based index) for multi-chunk bundles. - Updates
<themePath>/bundles/manifest.jsonso templates can look up the current chunk list per bundle and emit them by position.
Bundle filenames are content-hashed, so unchanged source produces the
same filename and a rebuild is a no-op. Templates that include the
bundle URL can be served Cache-Control: public, max-age=31536000,
immutable safely. Per-chunk hashing means changing one chunk's source
doesn't invalidate the others' cache URLs.
Domain layout
All bundler code lives under Domain/Asset/:
| Class | Responsibility |
|---|---|
Plugins\Contents\Domain\Asset\AssetBundleSpec |
Value object — loads <themePath>/asset-bundles.php, validates each entry (files required, optional position / max_size), and exposes accessors. |
Plugins\Contents\Domain\Asset\AssetBundleChunker |
Pure splitter — given a list of [path, size] and an optional max_size, returns ordered chunks of file paths. Order-preserving greedy first-fit. |
Plugins\Contents\Domain\Asset\AssetMinifier |
Adapter over MatthiasMullie\Minify\CSS and \JS. One method per type. |
Plugins\Contents\Domain\Asset\AssetBundleBuilder |
Orchestrator. build() chunks each bundle, writes hashed files atomically, prunes orphans, returns the new manifest. |
Plugins\Contents\Domain\Asset\AssetManifest |
Read / write the JSON manifest. loadByPosition($themePath, 'head' \| 'body') is the position-filtered convenience templates call at render time. |
Plugins\Contents\Domain\Asset\AssetBundleException |
Typed exception for the bundler. |
Theme contract
A theme that wants bundling drops an asset-bundles.php at its root.
Each bundle is an associative array with files (required) plus
optional position and max_size:
<?php
// <theme>/asset-bundles.php
// Bundle names end in _css or _js — that suffix decides the output
// extension AND the default position (_css → head, _js → body).
return [
'vendor_css' => [
'position' => 'head', // optional; defaults from suffix
'files' => [
'css/library/bootstrap.min.css',
'css/library/font-awesome.min.css',
// …
],
],
'vendor_js' => [
'position' => 'head', // override the _js default
'max_size' => 200 * 1024, // optional — split into ~200 KB chunks
'files' => [
'js/library/jquery-1.11.0.min.js', // deps first; order is preserved
'assets/js/jimbo.js',
// …
],
],
'site_js' => [
// position omitted → defaults to 'body' for _js bundles
'files' => [
'js/scripts.js',
// …
],
],
];
Validation rules:
- files is required and must be a non-empty array of strings.
- position is 'head' or 'body'; defaults to 'head' for _css
names, 'body' for _js names.
- max_size is a positive int (bytes) or omitted; omitted means "no
split, single chunk regardless of size".
- Unknown keys in a bundle entry are rejected (catches typos like
max_sze).
- Unknown bundle name suffixes are rejected (the suffix decides the
extension and the default position).
Manifest shape
<themePath>/bundles/manifest.json:
{
"built_at": "2026-06-04T15:00:00+00:00",
"bundles": [
{"name": "vendor_css", "ext": "css", "position": "head",
"files": ["vendor_css.56f9f99d.min.css"]},
{"name": "vendor_js", "ext": "js", "position": "head",
"files": ["vendor_js.1.aaaaaaaa.min.js", "vendor_js.2.bbbbbbbb.min.js"]},
{"name": "site_js", "ext": "js", "position": "body",
"files": ["site_js.dbc35d24.min.js"]}
]
}
bundles is an ordered list (not a map) so emission order is explicit;
each entry's files is always a non-empty array so templates have one
code path regardless of chunk count.
At render time, templates check the host site's assets_bundling_enabled
setting and call AssetManifest::loadByPosition($themePath, 'head' | 'body')
to get the head- and body-bound bundles separately. If the manifest is
missing or the toggle is off, the template should emit the individual
files just like it did before bundling existed.
CLI invocation
The bundler is invoked via the generic --mode exec of festi-plugin
(added by the companion change in festi-team/festi-framework-cli):
docker compose exec -T [YOUR_CONTAINER] sh -lc '
cd /var/www/html/src/site &&
./vendor/bin/festi-plugin \
--mode exec --plugin Contents --method bundleAssets \
--path /var/www/html/src/site
'
The command prints the new manifest as JSON. Wire it into a deploy step,
or commit the produced files (they're content-hashed and immutable, so
re-builds are idempotent and committing them is safe).
Dependencies
matthiasmullie/minify is not declared in this plugin's
composer.json — the bundler is opt-in and we don't want to make every
consumer of Contents install it. Host sites that opt in declare the
dependency in their own composer.json; PHP's lazy use statements
mean the minifier classes only load when bundleAssets() is actually
called.
Security
- Content is automatically escaped to prevent XSS attacks
- User role-based access control for content visibility
- Secure handling of file paths and content