Creating Extensions #

Extensions bundle custom directives, compiler passes, and runtime services into a reusable package. Extension internals now live under Sugar\Core\..., while optional extension packages live under Sugar\Extension\....

Basic Extension #

use Sugar\Core\Extension\ExtensionInterface;
use Sugar\Core\Extension\RegistrationContext;

final class AuditExtension implements ExtensionInterface
{
    public function register(RegistrationContext $context): void
    {
        $context->directive('audit', AuditDirective::class);
        $context->compilerPass(
            new AuditPass(),
            \Sugar\Core\Compiler\Pipeline\Enum\PassPriority::POST_DIRECTIVE_COMPILATION,
        );
    }
}

Register the extension with the engine builder:

use Sugar\Core\Engine;

$engine = Engine::builder()
    ->withTemplateLoader($loader)
    ->withExtension(new AuditExtension())
    ->build();

Registering Runtime Services #

Extensions can provide runtime services that directives and generated runtime calls consume:

use Sugar\Core\Extension\ExtensionInterface;
use Sugar\Core\Extension\RegistrationContext;

final class MetricsExtension implements ExtensionInterface
{
    public function __construct(private MetricsClient $metrics)
    {
    }

    public function register(RegistrationContext $context): void
    {
        $context->runtimeService('metrics', $this->metrics);
        $context->directive('track', TrackDirective::class);
    }
}

Runtime services are available through RuntimeEnvironment during template execution.

runtimeService() also supports factories (closures) that receive a RuntimeContext at render time:

use Sugar\Core\Extension\RuntimeContext;

$context->runtimeService('metrics', function (RuntimeContext $runtimeContext): MetricsClient {
    $compiler = $runtimeContext->getCompiler();

    return new MetricsClient($compiler);
});

Service IDs are plain strings. Using class-string IDs (for example, MetricsClient::class) is recommended for type-safe lookups with RuntimeEnvironment::requireService().

Use protectedRuntimeService() for critical services that must not be overridden by later extensions:

$context->protectedRuntimeService(MetricsClient::class, function (RuntimeContext $runtimeContext): MetricsClient {
    return new MetricsClient($runtimeContext->getCompiler());
});

For services that need both phases, capture registration-time dependencies from RegistrationContext and use RuntimeContext only for runtime-only dependencies.

Contexts: Registration vs Runtime #

Sugar uses two different context objects to avoid mixing extension phases.

RegistrationContext (build time) #

RegistrationContext is passed to ExtensionInterface::register() and is used to register directives, compiler passes, and runtime services.

Available getters:

  • getConfig()
  • getTemplateLoader()
  • getTemplateCache()
  • getTemplateContext() (may be null when no template context is configured)
  • isDebug()
  • getParser()
  • getDirectiveRegistry()

All registration dependencies listed above are non-null, except getTemplateContext(), which may return null.

RuntimeContext (render time) #

RuntimeContext is passed only to runtime service factories registered via runtimeService() / protectedRuntimeService().

Available getters:

  • getCompiler()
  • getTracker()

RuntimeContext intentionally contains only runtime-only dependencies that are not part of RegistrationContext.

Pattern for Using Both #

Use RegistrationContext in register() for stable engine services, and capture what you need into the factory closure. Use RuntimeContext inside the closure only for runtime-only services.

use Sugar\Core\Extension\RegistrationContext;
use Sugar\Core\Extension\RuntimeContext;

public function register(RegistrationContext $context): void
{
    $cache = $context->getTemplateCache();
    $debug = $context->isDebug();

    $context->runtimeService('metrics', function (RuntimeContext $runtimeContext) use ($cache, $debug): MetricsClient {
        return new MetricsClient(
            compiler: $runtimeContext->getCompiler(),
            cache: $cache,
            debug: $debug,
            tracker: $runtimeContext->getTracker(),
        );
    });
}

For example, fragment caching is now registered as an optional extension:

use Sugar\Core\Engine;
use Sugar\Extension\FragmentCache\FragmentCacheExtension;

$engine = Engine::builder()
    ->withTemplateLoader($loader)
    ->withExtension(new FragmentCacheExtension($cache, defaultTtl: 300))
    ->build();

Registering Directives #

Directives can be registered as instances or class strings:

$context->directive('tooltip', TooltipDirective::class);
$context->directive('badge', new BadgeDirective());

For directive design details, see the Custom Directives guide.

Element syntax for directives

A directive can additionally implement ElementClaimingDirectiveInterface to expose a custom element tag (<s-youtube src="$id">) alongside the standard attribute syntax (<div s:youtube="$id">). No extra compile logic is required — the engine converts element invocations to directive attributes automatically before extraction runs. See ElementClaimingDirectiveInterface for the full example.

Registering Compiler Passes #

Compiler passes run during AST compilation and can rewrite or validate nodes. Use them for cross-cutting transformations that are not tied to a single directive.

Each before()/after() hook returns a NodeAction. Most passes return NodeAction::none() to keep the node unchanged, but you can also replace nodes or skip child traversal when needed:

use Sugar\Core\Compiler\Pipeline\NodeAction;

return NodeAction::none();
use Sugar\Core\Compiler\Pipeline\NodeAction;

return NodeAction::skipChildren();
use Sugar\Core\Ast\TextNode;
use Sugar\Core\Compiler\Pipeline\NodeAction;

$replacement = new TextNode('replacement', $node->line, $node->column);

return NodeAction::replace([$replacement]);

Simple Example #

use Sugar\Core\Ast\Node;
use Sugar\Core\Ast\TextNode;
use Sugar\Core\Compiler\Pipeline\AstPassInterface;
use Sugar\Core\Compiler\Pipeline\NodeAction;
use Sugar\Core\Compiler\Pipeline\PipelineContext;

final class UppercaseTextPass implements AstPassInterface
{
    public function before(Node $node, PipelineContext $context): NodeAction
    {
        if ($node instanceof TextNode) {
            $node->content = strtoupper($node->content);
        }

        return NodeAction::none();
    }

    public function after(Node $node, PipelineContext $context): NodeAction
    {
        return NodeAction::none();
    }
}
use Sugar\Core\Ast\ElementNode;
use Sugar\Core\Ast\Node;
use Sugar\Core\Compiler\Pipeline\AstPassInterface;
use Sugar\Core\Compiler\Pipeline\NodeAction;
use Sugar\Core\Compiler\Pipeline\PipelineContext;
use Sugar\Core\Exception\CompilationException;

final class NoInlineStylesPass implements AstPassInterface
{
    public function before(Node $node, PipelineContext $context): NodeAction
    {
        if ($node instanceof ElementNode && array_key_exists('style', $node->attributes)) {
            throw new CompilationException('Inline styles are not allowed.');
        }

        return NodeAction::none();
    }

    public function after(Node $node, PipelineContext $context): NodeAction
    {
        return NodeAction::none();
    }
}
use Sugar\Core\Ast\Node;
use Sugar\Core\Ast\TextNode;
use Sugar\Core\Compiler\Pipeline\AstPassInterface;
use Sugar\Core\Compiler\Pipeline\NodeAction;
use Sugar\Core\Compiler\Pipeline\PipelineContext;

final class NormalizeWhitespacePass implements AstPassInterface
{
    public function before(Node $node, PipelineContext $context): NodeAction
    {
        if ($node instanceof TextNode) {
            $node->content = preg_replace('/\s+/', ' ', $node->content) ?? $node->content;
        }

        return NodeAction::none();
    }

    public function after(Node $node, PipelineContext $context): NodeAction
    {
        return NodeAction::none();
    }
}

Register it with a semantic priority:

use Sugar\Core\Compiler\Pipeline\Enum\PassPriority;

$context->compilerPass(new UppercaseTextPass(), PassPriority::POST_DIRECTIVE_COMPILATION);

When to Use a Compiler Pass #

  • You need to transform many nodes across the tree (normalization, instrumentation, linting).
  • You want to enforce a policy (for example, disallow inline styles or rewrite specific attributes).
  • You need a compile-time optimization (folding constants, removing empty nodes).

When to Prefer a Directive #

  • The behavior is scoped to a single directive or attribute.
  • You need direct access to the directive expression and local node context.
  • The feature should be opt-in on a per-element basis.

Priorities #

Compiler passes now use enum priorities (Sugar\Core\Compiler\Pipeline\Enum\PassPriority) instead of numeric values:

  • PRE_DIRECTIVE_EXTRACTION
  • ELEMENT_ROUTING — built-in; converts <s-NAME> element-claiming directive tags to FragmentNode
  • DIRECTIVE_EXTRACTION
  • DIRECTIVE_PAIRING
  • DIRECTIVE_COMPILATION
  • POST_DIRECTIVE_COMPILATION
  • CONTEXT_ANALYSIS
use Sugar\Core\Compiler\Pipeline\Enum\PassPriority;

$context->compilerPass(new NormalizePass(), PassPriority::PRE_DIRECTIVE_EXTRACTION);
$context->compilerPass(new OptimizePass(), PassPriority::POST_DIRECTIVE_COMPILATION);
$context->compilerPass(new FinalizePass(), PassPriority::CONTEXT_ANALYSIS);

Multiple Extensions #

Extensions are applied in the order you register them. For passes with the same enum priority, that registration order is preserved.

$engine = Engine::builder()
    ->withTemplateLoader($loader)
    ->withExtension(new AnalyticsExtension())
    ->withExtension(new SeoExtension())
    ->build();