The four templates in chapter 3 each carried their own <head> and <body> boilerplate. That works for a sketch; it does not scale. This chapter does three things:

  1. extracts the chrome into reusable partials,
  2. introduces the resources/ folder and themeAssetUrl() for shipping CSS and JS,
  3. promotes Atelier from a "uses the default Site" theme to one with its own AtelierTheme subclass (needed because the top-nav has to merge DB-pages with plugin-contributed NavItems from the Frontend Nav Registry).

If you have not read Concepts → Frontend Nav Registry, skim it now. The merge logic in this chapter assumes the terminology.

Layout partials

Make a resources/partials/ folder and put three files in it:

resources/partials/head.php:

<!doctype html>
<html lang="<?= htmlspecialchars((string) ($config['lang'] ?? 'en')) ?>">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>
        <?= htmlspecialchars((string) $site->page->name) ?> —
        <?= htmlspecialchars((string) ($config['site_name'] ?? 'Atelier')) ?>
    </title>
    <link rel="stylesheet" href="<?= htmlspecialchars($site->themeAssetUrl('css/styles.css')) ?>">
</head>

resources/partials/header.php:

<header class="site-header">
    <div class="site-header__inner">
        <a class="site-header__brand" href="<?= htmlspecialchars($site->siteUrl) ?>">
            <?= htmlspecialchars((string) ($config['site_name'] ?? 'Atelier')) ?>
        </a>
        <nav class="site-nav" aria-label="Main">
            <ul><?= $site->render('navigation') ?></ul>
        </nav>
    </div>
</header>

resources/partials/footer.php:

<footer class="site-footer">
    <p>&copy; <?= date('Y') ?> Atelier</p>
</footer>

Now replace each chapter-3 template with its partials-based version. The chrome — head.php include, body open, header.php include at the top; footer.php include and closing tags at the bottom — is the same in all four files. Only the body class and the middle differ.

Overwrite themes/atelier/basic.php with:

<?php include __DIR__ . '/resources/partials/head.php'; ?>
<body class="page page--basic">
    <?php include __DIR__ . '/resources/partials/header.php'; ?>
    <main>
        <article>
            <h1><?= htmlspecialchars((string) $site->page->name) ?></h1>
            <?= $site->render('messages') ?>
            <?= $site->render('content') ?>
        </article>
    </main>
    <?php include __DIR__ . '/resources/partials/footer.php'; ?>
</body>
</html>

Overwrite themes/atelier/home.php with:

<?php include __DIR__ . '/resources/partials/head.php'; ?>
<body class="page page--home">
    <?php include __DIR__ . '/resources/partials/header.php'; ?>
    <main>
        <header class="hero">
            <h1>Atelier</h1>
            <p>Independent design + build. Selected work below.</p>
            <p>
                <a href="<?= htmlspecialchars($site->siteUrl) ?>/projects/">See projects</a> ·
                <a href="<?= htmlspecialchars($site->siteUrl) ?>/contact/">Get in touch</a>
            </p>
        </header>

        <section class="home-intro">
            <?= $site->render('content') ?>
        </section>
    </main>
    <?php include __DIR__ . '/resources/partials/footer.php'; ?>
</body>
</html>

Overwrite themes/atelier/projects.php with:

<?php $children = $site->pages->findActiveByParent((int) $site->page->id()); ?>
<?php include __DIR__ . '/resources/partials/head.php'; ?>
<body class="page page--projects">
    <?php include __DIR__ . '/resources/partials/header.php'; ?>
    <main>
        <header>
            <h1><?= htmlspecialchars((string) $site->page->name) ?></h1>
            <?= $site->render('content') ?>
        </header>

        <?php if ($children === []): ?>
            <p>No projects published yet.</p>
        <?php else: ?>
            <ul class="project-grid">
                <?php foreach ($children as $child): ?>
                    <li>
                        <a href="<?= htmlspecialchars($site->siteUrl . '/' . $site->page->slug . '/' . $child->slug . '/') ?>">
                            <?= htmlspecialchars($child->name) ?>
                        </a>
                    </li>
                <?php endforeach ?>
            </ul>
        <?php endif ?>
    </main>
    <?php include __DIR__ . '/resources/partials/footer.php'; ?>
</body>
</html>

Overwrite themes/atelier/contact.php with:

<?php include __DIR__ . '/resources/partials/head.php'; ?>
<body class="page page--contact">
    <?php include __DIR__ . '/resources/partials/header.php'; ?>
    <main>
        <article>
            <h1><?= htmlspecialchars((string) $site->page->name) ?></h1>
            <?= $site->render('messages') ?>
            <?= $site->render('content') ?>

            <form class="contact-form" method="post" action="">
                <input type="hidden" name="action" value="contact">
                <p>
                    <label for="contact-name">Name</label>
                    <input id="contact-name" name="name" type="text" required>
                </p>
                <p>
                    <label for="contact-email">Email</label>
                    <input id="contact-email" name="email" type="email" required>
                </p>
                <p>
                    <label for="contact-message">Message</label>
                    <textarea id="contact-message" name="message" rows="6" required></textarea>
                </p>
                <p><button type="submit">Send</button></p>
            </form>
        </article>
    </main>
    <?php include __DIR__ . '/resources/partials/footer.php'; ?>
</body>
</html>

The chrome is now in one place.

Assets via themeAssetUrl()

This is where the empty public/themes/atelier/ directory you created in chapter 2 earns its keep. $site->themeAssetUrl('foo.css') resolves to https://your-site/themes/atelier/foo.css, which the web server serves from public/themes/atelier/foo.css. The helper does no existence checking; that's the browser's problem. The helper just builds a URL.

Create the CSS file at public/themes/atelier/css/styles.css with whatever taste you like. A two-minute starter:

:root {
    --bg:   #faf9f6;
    --ink:  #111;
    --muted:#666;
    --accent:#c14e2c;
    --max:  920px;
}
* { box-sizing: border-box; }
body { font-family: ui-serif, Georgia, serif; color: var(--ink); background: var(--bg); margin: 0; line-height: 1.55; }
.site-header__inner, main, .site-footer { max-width: var(--max); margin: 0 auto; padding: 1rem; }
.site-header { border-bottom: 1px solid #eee; }
.site-header__inner { display: flex; align-items: baseline; gap: 2rem; }
.site-header__brand { font-weight: 700; font-size: 1.25rem; color: var(--ink); text-decoration: none; }
.site-nav ul { list-style: none; padding: 0; margin: 0; display: flex; gap: 1.25rem; }
.site-nav a { color: var(--muted); text-decoration: none; }
.site-nav .current > a, .site-nav .active > a { color: var(--accent); }
.hero { padding: 4rem 1rem; text-align: center; }
.hero h1 { font-size: 3rem; margin: 0 0 .5rem; }
.project-grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
.project-grid a { display: block; padding: 1.5rem; border: 1px solid #eee; text-decoration: none; color: var(--ink); }
.contact-form label { display: block; font-weight: 600; margin-bottom: .25rem; }
.contact-form input, .contact-form textarea { width: 100%; padding: .5rem .75rem; border: 1px solid #ccc; border-radius: 4px; background: #fff; font: inherit; }
.contact-form input:focus, .contact-form textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.contact-form button { padding: .5rem 1.5rem; background: var(--accent); color: #fff; border: 0; border-radius: 4px; cursor: pointer; font: inherit; }
.contact-form button:hover { filter: brightness(.92); }
/* Message slot rendered by $site->render('messages'). Markup is
   <ul class="messages"><li class="msg msg-{type}">, same shape as
   the editor — chapter 6 wires the contact form to addMsg() and
   the rules below paint the result. */
ul.messages { list-style: none; margin: 1.5rem 0; padding: 0; }
.messages .msg { padding: .75rem 1rem; border-radius: 4px; border: 1px solid transparent; margin-bottom: .5rem; }
.messages .msg:last-child { margin-bottom: 0; }
.msg-success { background: #e8f5ec; color: #1f6b3a; border-color: #b7dcc0; }
.msg-error   { background: #fbeaea; color: #8c2222; border-color: #e6b7b7; }
.site-footer { color: var(--muted); border-top: 1px solid #eee; }

Reload. Atelier should now look like one site, not four loose pages. The CSS file is hand-written; no build step required.

Gotcha: browser cache. Scriptor does not version asset URLs by default. If you change styles.css and the browser keeps serving the old copy, hard-reload (Cmd-Shift-R / Ctrl-F5) or append $site->version as a query string in head.php, e.g. css/styles.css?v=<?= $site->version ?>. Bumping the version in your config (or in a theme subclass) busts every asset URL at once.

Why the top-nav needs a Site subclass

The default Site::render('navigation') only lists active top-level DB pages. That works for a flat site, but Atelier wants to mix in plugin-contributed entries, e.g. the Blog you'll add in chapter 5, which lives outside the page tree.

Plugins contribute top-level nav by registering a builder with the FrontendNavRegistry. Themes consume those entries by calling FrontendNavRegistry::collect() and merging them into their own nav by position. The default Site does not do that merge, because it does not know what your theme's <li> markup looks like. So you write the merge in your own Site subclass.

If $site->render('navigation') returning the bare DB list is fine for your project, you can stop reading the rest of this chapter and ship without a subclass. Atelier needs the merge, so read on.

A minimal AtelierTheme subclass

This is the first PHP class the theme owns, so it is also the first time composer install actually has work to do. Create the lib/ directory now (chapter 2 deferred it to here) and drop the class file into it:

mkdir themes/atelier/lib

Then create themes/atelier/lib/AtelierTheme.php:

<?php

declare(strict_types=1);

namespace Themes\Atelier;

use Scriptor\Boot\Frontend\Nav\FrontendNavRegistry;
use Scriptor\Boot\Frontend\Site;

final class AtelierTheme extends Site
{
    public function render(string $element): ?string
    {
        return match ($element) {
            'navigation' => $this->renderTopNav(),
            default      => parent::render($element),
        };
    }

    private function renderTopNav(): string
    {
        // `urlSegments->path()` returns 'about/' (no leading slash)
        // and '' for the home page, so prepend '/' to match the
        // '/slug/' format DB pages and NavItems use.
        $currentPath = '/' . $this->urlSegments->path(trailingSlash: true);
        $items       = [];

        // DB pages: every active top-level page becomes one nav entry.
        // `getPageUrl()` walks the parent chain and joins slugs with
        // trailing slashes, and crucially returns '' for the home
        // page (the one whose slug is empty), so the home nav link
        // canonicalises to '/' instead of '/home/'. No duplicate URLs
        // to confuse crawlers. The active-state check below excludes
        // the Home item from prefix-matching, because every path
        // starts with '/' and Home would otherwise light up on every
        // sub-page.
        foreach ($this->pages->findActiveByParent(0) as $dbPage) {
            $items[] = [
                'pos'  => $dbPage->item->position,
                'html' => $this->renderNavLi('/' . $this->getPageUrl($dbPage), $dbPage->name, $currentPath),
            ];
        }

        // Plugin-contributed entries: each NavItem becomes one nav
        // entry, merged into the same list by its position.
        $registry = $this->container->get(FrontendNavRegistry::class);
        if ($registry instanceof FrontendNavRegistry) {
            foreach ($registry->collect($this->urlSegments) as $item) {
                $items[] = [
                    'pos'  => $item->position,
                    'html' => $this->renderNavLi($item->url, $item->label, $currentPath),
                ];
            }
        }

        usort($items, static fn ($a, $b) => $a['pos'] <=> $b['pos']);

        return implode('', array_column($items, 'html'));
    }

    private function renderNavLi(string $url, string $label, string $currentPath): string
    {
        $cls = '';
        if ($url === $currentPath) {
            $cls = ' class="current"';
        } elseif ($url !== '/' && str_starts_with($currentPath, $url)) {
            // Exclude Home from prefix-matching: every $currentPath
            // starts with '/', so without this guard Home would be
            // marked active on every sub-page.
            $cls = ' class="active"';
        }
        return sprintf(
            '<li%s><a href="%s">%s</a></li>',
            $cls,
            htmlspecialchars($this->siteUrl . $url, \ENT_QUOTES),
            htmlspecialchars($label, \ENT_QUOTES),
        );
    }
}

Now generate the theme's autoloader so Composer picks up the new class:

cd themes/atelier
composer install

That creates themes/atelier/vendor/autoload.php with AtelierTheme in the classmap. The _ext.php you write next requires this file; without it PHP will throw "Class Themes\Atelier\AtelierTheme not found" on the next request. Any time you add or rename a class under lib/, re-run composer dump-autoload to refresh the classmap.

A few things to call out:

  • One NavItem field per concern. url, label, position, optional children. There are deliberately no isCurrent / isActive flags on the DTO, because the contributor cannot know what the current request is. That decision belongs to the consumer (you, here).
  • position is the only sort key. DB pages bring their Item::position; plugin entries bring whatever weight the contributor put on the NavItem. The two pools merge into one sorted list. PHP 8's usort is stable, so equal positions preserve insertion order (DB pages before plugin entries) without needing a manual tiebreaker.
  • registry->collect($this->urlSegments) passes the current request to the registry so plugins can build their entries context-aware. The markdown-pages plugin uses it to decide whether the Blog entry should be highlighted.
  • Use getPageUrl(), not handcrafted '/' . $slug . '/'. The helper walks the parent chain (so a child renders as /projects/project-a/, not just /project-a/) and — crucially — collapses the home page to '' because Scriptor's home convention is "the page whose slug is empty". With that, the Home nav link is always /, never /home/, and the currentPath === $url check below catches both flavours uniformly without a special case. Sites that prefer a /home/ URL can give the page a non-empty slug in the editor; / then 404s and /home/ becomes the only home — Scriptor doesn't force a choice, but the convention emits one canonical URL either way.

Wire the subclass via _ext.php

You also need to tell Scriptor's front controller to instantiate AtelierTheme instead of the default Site. That is what _ext.php does: the front controller looks for it next to the theme's template.php and includes it before template.php.

Create themes/atelier/_ext.php:

<?php

declare(strict_types=1);

use Scriptor\Boot\App;
use Themes\Atelier\AtelierTheme;

require_once __DIR__ . '/vendor/autoload.php';

/** @var array<string, mixed> $config */
// Scriptor root = themes/atelier/ → ../../ (two `dirname` calls).
$site = new AtelierTheme(App::container(), $config, dirname(__DIR__, 2));
$site->execute();

Three lines of substance:

  1. require the theme's own vendor/autoload.php so the Themes\Atelier\ classmap actually loads.
  2. Instantiate AtelierTheme with the container, the live config, and Scriptor's root directory.
  3. Call execute(), which resolves the URL to a Page and runs the theme through its event lifecycle.

template.php from chapter 2 does not change. The dispatcher already runs against whatever $site is in scope, so swapping the class is enough.

Reload the site. The nav should now show every DB top-level page in position order, and the current page should pick up class="current" (any ancestor of it gets class="active"). You don't have any plugin contributors yet; that lands in the next chapter.

Gotcha: forgetting composer dump-autoload. Adding a class under lib/ does not magically appear in the classmap. Inside the theme folder, run composer dump-autoload whenever you add or rename a class. Composer rewrites vendor/composer/autoload_classmap.php, and the next request finds the class.

To-do before chapter 5

  • Three partials under resources/partials/, included from every page template.
  • public/themes/atelier/css/styles.css exists and is referenced via themeAssetUrl() in head.php.
  • lib/AtelierTheme.php and _ext.php exist; composer install (or composer dump-autoload) ran in themes/atelier/.
  • The top-nav renders every top-level DB page in order, with current / active classes.

Atelier is now a real-looking site. Chapter 5 adds the blog, which is where the FrontendNav merge stops being theoretical and actually merges something.


Behind the scenes. The NavItem DTO is at boot/Frontend/Nav/NavItem.php. The FrontendNavRegistry lives next to it. A worked example of the same merge, in production, is the InfoTheme that ships this site, see scriptor-cms-site/theme/themes/info/lib/InfoTheme.php, renderHierarchicalNavigation().