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:
- extracts the chrome into reusable partials,
- introduces the
resources/folder andthemeAssetUrl()for shipping CSS and JS, - promotes Atelier from a "uses the default
Site" theme to one with its ownAtelierThemesubclass (needed because the top-nav has to merge DB-pages with plugin-contributedNavItems 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>© <?= 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.cssand the browser keeps serving the old copy, hard-reload (Cmd-Shift-R / Ctrl-F5) or append$site->versionas a query string inhead.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
NavItemfield per concern.url,label,position, optionalchildren. There are deliberately noisCurrent/isActiveflags on the DTO, because the contributor cannot know what the current request is. That decision belongs to the consumer (you, here). positionis the only sort key. DB pages bring theirItem::position; plugin entries bring whatever weight the contributor put on theNavItem. The two pools merge into one sorted list. PHP 8'susortis 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 thecurrentPath === $urlcheck 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:
requirethe theme's ownvendor/autoload.phpso theThemes\Atelier\classmap actually loads.- Instantiate
AtelierThemewith the container, the live config, and Scriptor's root directory. - Call
execute(), which resolves the URL to aPageand 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 underlib/does not magically appear in the classmap. Inside the theme folder, runcomposer dump-autoloadwhenever you add or rename a class. Composer rewritesvendor/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.cssexists and is referenced viathemeAssetUrl()inhead.php. -
lib/AtelierTheme.phpand_ext.phpexist;composer install(orcomposer dump-autoload) ran inthemes/atelier/. - The top-nav renders every top-level DB page in order, with
current/activeclasses.
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().