Extensions #

Extensions let you attach custom PHP logic to your Glaze project. There are two independent mechanisms, both activated by the #[GlazeExtension] attribute:

  • Template helpers — invokable classes callable as $this->extension('name') from any Sugar template.
  • Build event subscribers — classes with methods decorated with #[ListensTo] that react to specific moments in the build pipeline.

A single class can do both at the same time.

The #[GlazeExtension] attribute #

The attribute accepts two parameters:

Parameter Type Purpose
name string|null Extension identity. Used as the config key for opt-in and as the template helper name. null for anonymous auto-discovered-only classes.
helper bool When true, registers the class as a named template helper. Requires a non-empty name and __invoke(). Defaults to false.

Having a name alone does not register a template helper — you must opt in explicitly with helper: true. This means a named class without helper: true is addressable from glaze.neon but invisible to templates.

Auto-discovery #

Create an extensions/ directory at your project root. Any class decorated with #[GlazeExtension] placed there is discovered and registered automatically.

The directory is extensions/ by default. Override it in glaze.neon:

paths:
    extensions: src/Extensions

Classes that do not carry #[GlazeExtension] are silently skipped, so helper files can live alongside extension classes without issue.


Template helpers #

A template helper is a named, invokable class. Provide a non-empty name, set helper: true, and implement __invoke().

extensions/LatestRelease.php:

<?php

use Glaze\Template\Extension\GlazeExtension;

#[GlazeExtension('version', helper: true)]
final class LatestRelease
{
    public function __invoke(): string
    {
        return trim((string)file_get_contents(__DIR__ . '/../VERSION'));
    }
}

Call it from any Sugar template:

<footer>Version <?= $this->extension('version') ?></footer>

Extension results are memoized per build – however many templates call the same extension, the underlying PHP runs exactly once.

Arguments are forwarded to __invoke() on its first invocation:

#[GlazeExtension('asset', helper: true)]
final class AssetExtension
{
    public function __invoke(string $path): string
    {
        return '/assets/' . ltrim($path, '/');
    }
}
<link rel="stylesheet" href="<?= $this->extension('asset', 'css/site.css') ?>">

Note: Because results are cached after the first call, passing different arguments on subsequent calls returns the result from the first invocation. Design extensions that take arguments as single-invocation helpers.

Calling an unregistered name throws a RuntimeException:

Glaze extension "versin" is not registered. Available: version, asset

Build event subscribers #

Extensions can also listen to events fired during the static build. Add one or more public methods decorated with #[ListensTo(BuildEvent::X)].

An anonymous subscriber needs no name — omit the argument entirely:

<?php

use Glaze\Build\Event\BuildCompletedEvent;
use Glaze\Build\Event\BuildEvent;
use Glaze\Build\Event\PageWrittenEvent;
use Glaze\Template\Extension\GlazeExtension;
use Glaze\Template\Extension\ListensTo;

#[GlazeExtension]
final class SitemapGenerator
{
    private array $urls = [];

    #[ListensTo(BuildEvent::PageWritten)]
    public function collect(PageWrittenEvent $event): void
    {
        $this->urls[] = $event->page->urlPath;
    }

    #[ListensTo(BuildEvent::BuildCompleted)]
    public function write(BuildCompletedEvent $event): void
    {
        $sitemap = $this->buildSitemap($this->urls, $event->config->site->baseUrl);
        file_put_contents($event->config->outputPath() . '/sitemap.xml', $sitemap);
    }
}

A named subscriber has a name but no helper: true. It is auto-discovered and can be opted into from glaze.neon (see Core extensions below), but it never registers as a template helper:

#[GlazeExtension('my-subscriber')]
final class MySubscriber
{
    #[ListensTo(BuildEvent::BuildCompleted)]
    public function run(BuildCompletedEvent $event): void { ... }
}

A class with helper: true and event listeners registers as both a template helper and a subscriber:

#[GlazeExtension('stats', helper: true)]
final class StatsExtension
{
    private int $pageCount = 0;

    #[ListensTo(BuildEvent::PageWritten)]
    public function count(PageWrittenEvent $event): void
    {
        $this->pageCount++;
    }

    public function __invoke(): int
    {
        return $this->pageCount;
    }
}

Build events reference #

Event Payload Mutable fields When
BuildEvent::BuildStarted BuildStartedEvent Before content discovery
BuildEvent::ContentDiscovered ContentDiscoveredEvent $pages After discovery, before rendering
BuildEvent::DjotConverterCreated DjotConverterCreatedEvent converter object After Djot converter creation, before convert
BuildEvent::SugarRendererCreated SugarRendererCreatedEvent renderer object When Sugar renderer instance is created
BuildEvent::PageRendered PageRenderedEvent $html After each page renders, before writing
BuildEvent::PageWritten PageWrittenEvent After each page is written to disk
BuildEvent::BuildCompleted BuildCompletedEvent After all pages and assets are written

Mutable fields let you modify the build in place. Set ContentDiscoveredEvent::$pages to inject or remove pages, or set PageRenderedEvent::$html to post-process rendered output.

BuildStartedEvent #

$event->config    — BuildConfig

Useful for opening file handles, recording a start time, or validating external prerequisites.

ContentDiscoveredEvent #

$event->pages     — ContentPage[]  (mutable)
$event->config    — BuildConfig

Mutate $pages to inject virtual pages, reorder, augment metadata, or filter the list.

Virtual pages #

A virtual page is a synthetic entry you inject into the page list via ContentDiscovered. Virtual pages participate in build progress output (the counter and label) but are completely invisible to the site index — they do not appear in navigation, sections, or template collections. The builder skips the Djot/Sugar render pipeline for them entirely; writing the actual file is the extension’s own responsibility, typically inside BuildCompleted.

Create one with the ContentPage::virtual() named constructor:

ContentPage::virtual(
    urlPath: '/sitemap.xml',           // canonical public URL
    outputRelativePath: 'sitemap.xml', // path under the output directory
    title: 'Sitemap',                  // label shown in build progress
    meta: [],                          // optional arbitrary metadata
)

A complete pattern — register in ContentDiscovered, write in BuildCompleted:

#[GlazeExtension('my-sitemap')]
final class SitemapExtension
{
    #[ListensTo(BuildEvent::ContentDiscovered)]
    public function registerVirtualPage(ContentDiscoveredEvent $event): void
    {
        $event->pages[] = ContentPage::virtual('/sitemap.xml', 'sitemap.xml', 'Sitemap');
    }

    #[ListensTo(BuildEvent::BuildCompleted)]
    public function write(BuildCompletedEvent $event): void
    {
        $xml = $this->buildXml($event->config->site->baseUrl);
        file_put_contents($event->config->outputPath() . '/sitemap.xml', $xml);
    }
}

The virtual page appears in the build progress counter alongside regular pages (3 / 18) and shows its urlPath as the label. It is never passed to PageRendered or PageWritten listeners.

DjotConverterCreatedEvent #

$event->converter — DjotConverter
$event->page      — ContentPage
$event->config    — BuildConfig

Useful for registering custom Djot extensions per page render:

#[ListensTo(BuildEvent::DjotConverterCreated)]
public function registerDjot(DjotConverterCreatedEvent $event): void
{
    $event->converter->addExtension(new MyDjotExtension());
}

SugarRendererCreatedEvent #

$event->renderer — SugarPageRenderer
$event->template — string
$event->config   — BuildConfig

Useful for registering custom Sugar extensions on newly-created page renderers:

#[ListensTo(BuildEvent::SugarRendererCreated)]
public function registerSugar(SugarRendererCreatedEvent $event): void
{
    $event->renderer->addExtension(new MySugarExtension());
}

PageRenderedEvent #

$event->page      — ContentPage
$event->html      — string  (mutable)
$event->config    — BuildConfig

Mutate $html to minify output, inject analytics snippets, or extract content for a search index.

PageWrittenEvent #

$event->page        — ContentPage
$event->destination — string  (absolute output path)
$event->config      — BuildConfig

Useful for accumulating sitemap entries, search-index records, or per-page statistics.

BuildCompletedEvent #

$event->writtenFiles — string[]  (absolute paths)
$event->config       — BuildConfig
$event->duration     — float  (seconds)

Useful for writing derived files (sitemap, search index, RSS feed) and triggering post-build hooks.


Core extensions #

Glaze ships with built-in opt-in extensions (sitemap, llms-txt, search-index) that are activated via the extensions key in glaze.neon. See Core extensions for the full reference.