Customize PagibleAI Theme

PagibleAI resolves each frontend page through its active theme and falls back to the base theme when a view or asset is missing. You can override the base theme inside one Laravel application or package a theme for reuse across projects.

Use application overrides for a small site-specific change. Create a theme package when the design has its own layouts, content elements, assets or configuration.

Current implementation references: frontend helper functions, base document layout, navigation view model and base theme schema. If you use a tagged release, compare signatures with the package installed in your application.

Publish and change theme assets

The base theme publishes CSS and JavaScript to public/vendor/cms/theme/. Run php artisan vendor:publish --tag=cms-theme after installation or when you deliberately want to publish missing assets.

You can edit an already-published file for a small application-specific change. Laravel does not overwrite an existing published file unless you publish with --force, so compare your copy with the package version during upgrades. For a reusable or extensive design, put the changed assets in a custom theme package instead.

The main files are:

  • cms.css for critical styles loaded in the document head
  • cms-lazy.css for styles loaded near the end of the document
  • cms.js and csrf.js for shared frontend behavior

Element templates can load their own assets with Blade stacks. For example:

@pushOnce('head')
<link href="{{ cmstheme($page, 'quote.css') }}" rel="stylesheet">
@endPushOnce

@pushOnce('foot')
<script defer src="{{ cmstheme($page, 'quote.js') }}"></script>
@endPushOnce

cmstheme() uses the page's active theme and falls back to public/vendor/cms/theme/ when that theme does not provide the requested file. @pushOnce prevents duplicate tags when an element appears more than once.

Override base-theme views

Laravel application overrides for the base cms view namespace live in resources/views/vendor/cms/. Copy only the templates you need from vendor/aimeos/pagible-theme/views/, keeping the same relative path.

Examples:

  • vendor/aimeos/pagible-theme/views/hero.blade.php → resources/views/vendor/cms/hero.blade.php
  • vendor/aimeos/pagible-theme/views/layouts/main.blade.php → resources/views/vendor/cms/layouts/main.blade.php

Never edit files inside vendor/; Composer replaces them during updates. See Laravel's package view customization documentation for the underlying override mechanism.

Understand the main layout

The base document layout is vendor/aimeos/pagible-theme/views/layouts/main.blade.php. It renders metadata, navigation and breadcrumbs, yields the page-specific sections, and then includes layouts/foot.blade.php for lazy assets and editor integration.

The site name shown in the brand link, the copyright line and the WebSite structured data comes from the website config element via cmsconfig($page, 'website.data.title', …), inherited from the nearest ancestor that sets it and falling back to the name of the root page. It no longer uses config('app.name').

If the page has no canonical meta element, the layout includes cms::canonical with the page URL. On paginated pages, that view appends ?p=N so each list page has its own canonical URL.

A shortened outline looks like this:

<!DOCTYPE html>
<html lang="{{ cms($page, 'lang') }}">
    <head>
        <title>{{ cms($page, 'title') }}</title>

        @unless(collect(cms($page, 'meta', []))->contains('type', 'canonical'))
            @include('cms::canonical', ['data' => (object) ['url' => cmsroute($page)]])
        @endunless

        @foreach(cms($page, 'meta', []) as $item)
            @includeFirst(cmsviews($page, $item), cmsdata($page, $item))
        @endforeach

        <link href="{{ cmstheme($page, 'cms.css') }}" rel="stylesheet">
        @stack('head')
    </head>
    <body>
        <header>
            <a href="{{ cmsroute($nav->ancestors()->first() ?? $page) }}">
                {{ cmsconfig($page, 'website.data.title', cms($page->ancestorsAndSelf->first() ?? $page, 'name')) }}
            </a>
        </header>

        <main id="main">
            @yield('main')
        </main>

        @yield('footer')
        @include('cms::layouts.foot')
    </body>
</html>

Content Security Policy

Outside debug mode, the base layout sends a Content Security Policy through a <meta> element. External sources come from the cms.theme.csp.* configuration, while inline styles and scripts configured on a page or ancestor are authorized by SHA-256 hashes generated with cmshashes().

The relevant directives are:

style-src 'self'
    {{ config('cms.theme.csp.style-src') }}
    {!! cmshashes($page, 'config.styles.data.text') !!};

script-src 'self'
    {{ config('cms.theme.csp.script-src') }}
    {!! cmshashes($page, 'config.javascript.data.text') !!};

Configure additional sources with:

  • CMS_CSP_MEDIA_SRC for images and media; its default includes the external demo-media and Iconify hosts
  • CMS_CSP_STYLE_SRC for stylesheets
  • CMS_CSP_FRAME_SRC for frames; its default is https://hcaptcha.com https://*.hcaptcha.com https://www.openstreetmap.org, which the map element needs
  • CMS_CSP_SCRIPT_SRC for scripts
  • CMS_CSP_CONNECT_SRC for browser connections

Add only sources you trust. If you override CMS_CSP_FRAME_SRC, keep https://www.openstreetmap.org when you use map elements. You do not need unsafe-inline for the page-level custom CSS and JavaScript fields because the layout calculates their hashes.

Head and foot assets

Use @stack('head') for critical element CSS and @stack('foot') for deferred assets. CSS that an element needs before first paint, for example the slideshow styles of a main hero or slideshow, is pushed to head as a regular stylesheet instead of being preloaded in foot.

When several elements share an asset, give @pushOnce a named key such as @pushOnce('head', 'cms-slideshow-head') or @pushOnce('foot', 'cms-slideshow'). Blocks with the same key are output only once, even if they come from different templates.

The base foot partial loads lazy theme files and conditionally adds the frontend editor:

<link href="{{ cmstheme($page, 'pico.modal.min.css') }}" rel="preload" as="style">
<link href="{{ cmstheme($page, 'cms-lazy.css') }}" rel="preload" as="style">
<script defer src="{{ cmstheme($page, 'csrf.js') }}"></script>
<script defer src="{{ cmstheme($page, 'cms.js') }}"></script>
@stack('foot')

@if(\Aimeos\Cms\Permission::can('page:save', auth()->user()))
    @includeIf('cms::editor')
@else
    <script defer src="{{ cmstheme($page, 'stats.js') }}"></script>
@endif

Non-critical CSS is loaded with rel="preload" as="style", so it doesn't block rendering. cms.js switches every link[rel="preload"][as="style"] to rel="stylesheet" once the page has loaded. Use the same pattern for element styles that aren't needed for the first paint, as the video element does:

@pushOnce('foot')
<link href="{{ cmstheme($page, 'video.css') }}" rel="preload" as="style">
@endPushOnce

If your theme replaces cms.js, keep this conversion or the preloaded stylesheets are never applied.

The cms::editor view is provided by the admin package. Keep this conditional include if editors should be able to open the visual page editor from the frontend.

Navigation

Layouts receive a request-local $nav object. It applies publication status and frontend access rules before returning items, so the template does not need to repeat those checks.

Render the configured navigation depth with $nav->items():

<ul>
    @foreach($nav->items() as $item)
        <li>
            @if($item->children->count())
                <details>
                    <summary>{{ cms($item, 'name') }}</summary>
                    <ul>
                        @foreach($item->children as $child)
                            <li>
                                <a href="{{ cmsroute($child) }}">
                                    {{ cms($child, 'name') }}
                                </a>
                            </li>
                        @endforeach
                    </ul>
                </details>
            @else
                <a href="{{ cmsroute($item) }}">
                    {{ cms($item, 'name') }}
                </a>
            @endif
        </li>
    @endforeach
</ul>

CMS_NAVDEPTH controls how many descendant levels are loaded; its default is 2. Pass a zero-based ancestor level to $nav->items($level) when a layout needs to start lower in the tree.

Breadcrumbs

Use the same navigation object for visible, access-filtered ancestors:

@if($nav->ancestors()->count() > 1)
    <nav aria-label="{{ __('Breadcrumb navigation') }}">
        <ul>
            @foreach($nav->ancestors()->skip(1) as $item)
                <li>
                    <a href="{{ cmsroute($item) }}">{{ cms($item, 'name') }}</a>
                </li>
            @endforeach
            <li>{{ cms($page, 'name') }}</li>
        </ul>
    </nav>
@endif

Build page layouts

Page layouts live below views/layouts/ and extend the active theme's main layout. Their editable regions must match the sections configured for that page type in schema.json.

The page type selects the layout. PagibleAI renders the first view that exists from this list:

  1. <theme>::layouts.<type> from the page's active theme
  2. cms::layouts.<type> from the base theme
  3. cms::layouts.page as the final fallback

The base theme ships page, docs, blog and news layouts. The news layout extends the blog layout. Pages of type news created in the last two days are listed in /sitemap-news.xml. A theme that offers news articles declares the news type with its sections in schema.json, as the news and journal themes do.

The base page layout renders the main and footer groups:

@extends($theme . '::layouts.main')

@pushOnce('head')
<link href="{{ cmstheme($page, 'layout-page.css') }}" rel="stylesheet">
@endPushOnce

@section('main')
    <div class="cms-content" data-section="main">
        @foreach($content['main'] ?? [] as $item)
            @if($element = cmsref($page, $item))
                <div id="{{ cmsattr($item->id ?? '') }}"
                    class="{{ cmsattr($element->type ?? '') }}">
                    <div class="container">
                        @includeFirst(
                            cmsviews($page, $element),
                            cmsdata($page, $element)
                        )
                    </div>
                </div>
            @endif
        @endforeach
    </div>
@endsection

@section('footer')
    <footer class="cms-content" data-section="footer">
        @foreach($content['footer'] ?? [] as $item)
            @if($element = cmsref($page, $item))
                <div id="{{ cmsattr($item->id ?? '') }}"
                    class="{{ cmsattr($element->type ?? '') }}">
                    <div class="container">
                        @includeFirst(
                            cmsviews($page, $element),
                            cmsdata($page, $element)
                        )
                    </div>
                </div>
            @endif
        @endforeach
    </footer>
@endsection

cms-content enables frontend editing for the region. data-section tells the editor where a newly inserted element belongs. cmsref() resolves shared-element references, cmsviews() returns the view candidates, and cmsdata() prepares the template variables.

Build content-element views

A content element's Blade filename matches its schema key. For a custom quote element, create views/quote.blade.php in the theme package:

@pushOnce('head')
<link href="{{ cmstheme($page, 'quote.css') }}" rel="stylesheet">
@endPushOnce

<figure class="quote">
    <blockquote>@text($data->text ?? '')</blockquote>

    @if($data->author ?? null)
        <figcaption>{{ $data->author }}</figcaption>
    @endif
</figure>

The template receives $page, $data and $files. Escape ordinary values with Blade's {{ ... }} syntax. Use @text for limited inline Markdown or @markdown for full Markdown. Resolve selected files with cmsfile($page, $id) and generate their URLs with cmsasset($page, $file).

Images, audio and video

Render images with the base theme's cms::pic partial instead of writing your own <img> tag. It outputs a <picture> with a responsive srcset, schema.org image markup and the file description as alt text in the page language:

@include('cms::pic', [
    'file' => $file,
    'class' => 'quote-image',
    'sizes' => '(max-width: 576px) 100vw, 33vw',
    'main' => $data->main ?? false,
])
  • sizes describes how wide the image is displayed (default 100vw) so the browser picks the right preview.
  • main marks the main image of the page, for example the first slide of a main hero. That image is loaded eagerly with fetchpriority="high" to improve Largest Contentful Paint. All other images use loading="lazy" and fetchpriority="low". Set main on one image per page at most.

PagibleAI generates WebP or JPEG previews in the sizes configured in cms.image.preview-sizes. The defaults are 480×270, 720×405, 960×540 and 1920×1080 pixels. cmssrcset() lists all of them. After changing the sizes, image format or quality, update the previews of existing images:

php artisan cms:previews
php artisan cms:previews --tenant=demo --id=<file-id>
php artisan cms:previews --force

Without options, the command creates the missing previews in the configured sizes for the files of all tenants. --tenant and --id limit the files it updates. --force replaces all previews, for example after you change CMS_IMAGE_QUALITY.

The base audio and video views use preload="none", so browsers don't download media files until the visitor starts playback. Video elements show the largest preview as poster image. Keep preload="none" in custom media templates to avoid loading large files on page load.

Create a reusable theme package

For a reusable design, keep the Composer manifest, schema, service provider, main layout, assets, translations and tests in a separate package. Follow Create a PagibleAI Theme Package for the package structure, local installation, regression test and release checklist.

Troubleshoot theme changes

  • The theme is missing in the page editor: confirm Composer discovered the service provider and that the same identifier is used by Schema::register(), the Blade namespace and the page's theme value.
  • A Blade change is not visible: run php artisan optimize:clear to clear compiled views and application caches.
  • A theme asset returns 404: run php artisan vendor:publish --tag=cms-theme and check public/vendor/cms/<theme>/. Add --force only when you intend to overwrite already published assets.
  • Styles pushed to foot are never applied: check that cms.js is loaded. It converts rel="preload" as="style" links into stylesheets.
  • Images are blurry or previews are missing: run php artisan cms:previews after changing the preview sizes.
  • The browser reports a CSP violation: add the required host to the matching CMS_CSP_* setting. Keep inline style and script output paired with the same cmshashes() config paths used by the CSP.

Template helper reference

  • cms($item, $property, $default = null) reads a nested published value, or the latest draft value for an editor with page:view.
  • cmsasset($page, $file, $variant = null) returns a public file URL or an access-controlled URL for a private file. The optional variant is a preview width or preview path.
  • cmsattr($value) sanitizes a value for an HTML id or class attribute.
  • cmsconfig($page, $path, $default = null) returns the page config value at $path (e.g. website.data.title) from the page or its nearest ancestor that sets a non-empty value, otherwise $default.
  • cmsdata($page, $item) prepares an element's template data, files and schema-defined action result.
  • cmsfile($page, $fileId) retrieves a file attached to the page.
  • cmshashes($page, $path) returns CSP hashes for inline style or script values inherited from the page tree.
  • cmsjson($value) encodes JSON safely for a <script> block.
  • cmslink($url) allows relative links and the http, https, mailto and tel schemes; unsafe schemes return an empty string.
  • cmsplain($markdown) creates best-effort plain text for structured data.
  • cmsref($page, $item) resolves a shared-element reference.
  • cmsroute($route, $params = [], $domain = null) returns the URL of a page, or its redirect target. With a route name such as cms.page, it generates that route and adds the domain parameter (explicit $domain or request host) in multi-domain setups.
  • cmssrcset($page, $file) builds responsive preview URLs for a file.
  • cmstheme($page, $file, $version = true) returns a theme asset URL with base-theme fallback and optional cache-busting version.
  • cmsurl($path) returns a URL from the configured public CMS disk while preserving remote HTTP URLs.
  • cmsviews($page, $item) returns ordered theme and base-theme view candidates, ending with cms::invalid.