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

  1. Place the plugin in your project's Plugins/Contents directory
  2. 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

  1. Database Content (db)
  2. Store content directly in the database
  3. Supports HTML content with automatic escaping

  4. File Content (file)

  5. Serve content from static files
  6. Files should be placed in the configured content directory
  7. Supports HTML files

  8. PHP Dynamic Pages (php)

  9. Create dynamic content using PHP
  10. Supports custom templates and logic
  11. Files should be placed in the configured content directory

  12. Plugin Content (plugin)

  13. Integrate with other plugins
  14. Custom plugin methods can be called to generate content

Template Integration

The plugin provides several template blocks for easy integration:

  • fetchLatest() - Display latest content items
  • getTagsByIdents() - Retrieve active tags for specific idents in custom page logic
  • fetchTagsBlock() - Display content tags
  • fetchSearchBlock() - Add search functionality
  • fetchContents() - Display content items
  • fetchPaginationBlock() - 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.

  • active tags are available to public helpers such as getTagsByIdents(), fetchTagsBlock(), fetchTagContentsBlock(), and getTagByIdent().
  • disabled tags 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 contents and contents_tags depends on the contents_manage permission 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-exec can 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_tags now has a required status field. Use active for visible frontend tags or disabled to keep a tag hidden from public helpers.
  • The tag relation does not use the DGS field name tags in the CLI payload. It uses the request key format m2m_<linkTable>, which for this plugin is m2m_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:

  • changefreq accepted values: always, hourly, daily, weekly, monthly, yearly, never.
  • priority is a decimal from 0.0 to 1.0 (step 0.1). Default is 0.5.
  • lastmod is optional. Format: YYYY-MM-DD.
  • status must be active or disabled. Only active rows appear in sitemap.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.

  1. Set the lang field on each page to its ISO 639-1 code (e.g. uk, en).
  2. Set the same group_ident value on all language variants of a page.
  3. The plugin automatically outputs <link rel="alternate" hreflang="..."> tags when a page has a group_ident, lang, and at least one other variant.
  4. The default language (used for x-default) is read from the default_lang setting, falling back to the core lang option.

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.sql
  • install/update.mysql.sql
  • install/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

  1. Open /manage/contents/tags/ and create the tag first.
  2. Open /manage/contents/ and create a new page.
  3. For a blog article, choose Source Type = Database.
  4. Fill Caption, Url, Description, Content, SEO fields, and Tags.
  5. 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:

  1. asset-bundles.php config — 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 with Bundle 'vendor_css' has unknown key(s): 0, 1, 2 because array_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.
  2. manifest.json shape — map → ordered list. The manifest's bundles field is now a list of {name, ext, position, files[]} objects, not a name → basename map. Code doing $manifest['bundles']['vendor_css'] (map access) now reads null from the list and emits empty bundle URLs. See Manifest shape below.
  3. AssetManifest::lookup() removed. Templates that called AssetManifest::lookup($themePath, 'site_css') to fetch a single bundle's basename must switch to AssetManifest::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's files[] 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:

  1. Reads the ordered list of theme-relative file paths.
  2. If the bundle has a max_size, splits the file list into ordered chunks via AssetBundleChunker (greedy first-fit, order-preserving so JS deps and CSS cascade stay intact).
  3. 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.
  4. 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.
  5. Updates <themePath>/bundles/manifest.json so 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