Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,62 @@ yours to choose. See the README's *Escaping Output* section.
- [ADD] **`Aura\View\Exception\HelperAlreadyRegistered`**, thrown by
`HelperRegistry::set()` on a collision. See *Breaking*.

- [ADD] **`parent()` -- extend a shadowed template instead of replacing it.**
Search paths are first-hit-wins, so a template in an earlier directory
shadows a later one and the shadowed version was *unreachable*: changing one
part of a package's template meant copying the whole file. `parent()`
renders the shadowed template by resuming the search after the directory the
current template came from, so only the difference lives in the application:

<?php $this->beginSection('extra') ?>
<p>Something only this application wants.</p>
<?php $this->endSection() ?>
<?= $this->parent() ?>

Chains are any depth, variables can be passed down with
`parent(['key' => $val])`, and namespaced names walk their own namespace's
paths. `parent()` returns `''` rather than throwing when there is nothing
further to render -- the template shadows nothing, came from the explicit
map, or the registry has no search paths -- because overriding a template
that turns out to shadow nothing is a normal state during development.
Calling it outside a render throws `Aura\View\Exception`.

- [ADD] **`setStrictParent()` / `isStrictParent()`, and
`Aura\View\Exception\ParentNotFound`.** `parent()` returning `''` when it
finds nothing is right for production but hides misconfiguration: a typo in a
search path, paths registered in the wrong order, or a registry with no
paths at all all produce the same `''`, so overrides silently stop composing
and the only symptom is missing markup. With strict parent mode on, those
three cases throw instead, naming the template and the reason:

parent() found no template to render for 'read': nothing after
'/app/templates' in the search paths has that name.

Off by default. It takes a bool rather than reading the environment itself --
Aura.View has no config layer and no dependencies, so what counts as
"development" belongs to whatever wires the _View_ up.

- [ADD] **`SearchPathInterface::getNext()` and `getResolvedPath()`**, plus the
readonly **`ResolvedTemplate`** (`name`, `template`, `path`) that `getNext()`
returns. `getResolvedPath()` reports which directory satisfied a name -- the
answer to "which package's template won?" -- and returns null for a mapped
or unresolvable name. `getNext()` is the resumption primitive behind
`parent()`. It returns a _ResolvedTemplate_ rather than a bare `\Closure`
because walking a chain past the first step needs the path the parent itself
came from, which a closure does not carry.

_TemplateRegistry_ now records the directory each found template came from
alongside the template itself; this cache is cleared with the existing one
whenever paths change.

- [ADD] **A render stack on _AbstractView_.** `render()` pushes the template
name and its resolved path for the duration of the render and pops it in a
`finally`, so nested renders resolve `parent()` against the template actually
executing, and a template that throws does not leave a frame behind.
`parent()` shares the existing `captureTemplate()` for its own buffering.
Subclasses that override `render()` wholesale will need to push and pop
themselves for `parent()` to work inside them.

- [ADD] **`ViewSpec`**, a readonly value object describing one template
registry: `map`, `paths`, `namespaces`, and `extension`. Its `newRegistry()`
method builds the corresponding _TemplateRegistry_, so it is useful when
Expand Down Expand Up @@ -182,8 +238,21 @@ yours to choose. See the README's *Escaping Output* section.
moves to `AbstractView::captureTemplate()`, which records the buffer and
capture depths on entry and restores both on failure.

- [FIX] `$capture` and `$section` initialise to `[]` rather than null;
appending to null is deprecated as of PHP 8.3.
- [FIX] `$capture` and `$section` initialise to `[]` rather than null, so
appending never relies on autovivification. (Appending to null still works
silently; it is autovivification from `false` that PHP 8.1 deprecated. The
explicit `[]` avoids depending on either.)

- [FIX] **`setPaths()` and `setNamespaces()` strip trailing directory
separators**, as `prependPath()` and `appendPath()` always have. Previously
the same directory had two spellings inside the registry depending on which
setter registered it, so `getPaths()` echoed back whatever it was given.
Beyond tidiness this broke `parent()`: the path recorded for a found
template is handed straight back to `getNext()` to resume the search, and a
directory stored one way but compared another made the shadowed template
unreachable -- `parent()` returned `''` instead of rendering it, going quiet
rather than failing loudly. `getPaths()` and `getNamespaces()` now return
normalised paths.

- [FIX] `TemplateRegistry::setTemplateFileExtension()` now clears the cache of
already-resolved templates, as every other path-mutating method already did. Changing
Expand Down
6 changes: 6 additions & 0 deletions docs/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,16 @@ interface SearchPathInterface
public function hasNamespace(string $namespace): bool;
public function getNamespaces(): array;
public function getNamespacePaths(string $namespace): array;
public function getResolvedPath(string $name): ?string;
public function getNext(string $name, string $afterPath): ?ResolvedTemplate;
public function setTemplateFileExtension(string $templateFileExtension): void;
}
```

`getResolvedPath()` reports which directory actually satisfied a name (null for a mapped or unresolvable one) -- the answer to "which package's template won?".

`getNext()` resumes the search after a given directory, which is what makes a shadowed template reachable; it backs [`parent()`](templates.md#extending-a-shadowed-template). It returns a _ResolvedTemplate_ -- a readonly `name` / `template` / `path` triple -- rather than a bare _\Closure_, because walking a chain more than one step needs the path the parent itself came from, and a closure does not carry that.

`getNamespaces()` returns the whole namespace-to-paths map, and
`getNamespacePaths()` returns the search paths for one namespace (an empty
array if that namespace is not registered). They answer "which directory did
Expand Down
78 changes: 78 additions & 0 deletions docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,84 @@ $view_registry->getNamespacePaths('no-such-namespace');

These are the namespaced counterparts of `getPaths()`; `hasNamespace()` reports whether a namespace is registered at all.

### Extending A Shadowed Template

Search paths are first-hit-wins, so a template in an earlier directory *shadows* one of the same name in a later directory. On its own that means a template can only be replaced wholesale: to change one part of a package's template, you copy the whole file into your own directory and edit it -- and it stops tracking the original from then on.

`parent()` renders the template the current one shadows, by resuming the search *after* the directory the current template was found in:

```php
<?php
$view_registry->setPaths([
'/app/templates', // searched first
'/vendor/acme/templates', // shadowed by /app
]);
?>
```

```php
<?php /* /vendor/acme/templates/read.php */ ?>
<h1><?= $this->title ?></h1>
<?= $this->getSection('extra') ?>
```

```php
<?php /* /app/templates/read.php -- shadows the one above */ ?>
<?php $this->beginSection('extra') ?>
<p>Something only this application wants.</p>
<?php $this->endSection() ?>
<?= $this->parent() ?>
```

Rendering `read` now runs the application's file, which sets a section and then renders the package's file, which picks that section up. Only the difference lives in the application.

Chains can be any depth -- an application shadowing a module shadowing a core default -- and each level calls `parent()` to reach the next. You can pass variables down:

```php
<?= $this->parent(['heading' => 'Custom']) ?>
```

`parent()` returns `''` rather than throwing when there is nothing further to render:

- the current template shadows nothing (common while developing -- you add an override before the thing it overrides exists, or the name is simply unique);
- the template came from the explicit map, which has no search path behind it;
- the registry does not implement _SearchPathInterface_ at all.

Those three are the answers to a well-formed question: there *is* a current template, and nothing follows it.

#### Strict Parent Mode

That forgiveness has a cost: a search path with a typo, a path registered in the wrong order, or a registry that turns out to have no paths at all produces exactly the same `''`. Overrides quietly stop composing and start replacing -- the page renders, nothing is raised, and the only symptom is missing markup.

Turn the three cases into _Aura\View\Exception\ParentNotFound_ while developing:

```php
<?php
$view->setStrictParent(true);
?>
```

The message names the template and why the lookup came up empty:

```text
parent() found no template to render for 'read': nothing after
'/app/templates' in the search paths has that name.
```

Leave it **off in production**, where the forgiving behaviour is what you want -- an override written before the template it overrides exists should not take a page down.

It takes a bool rather than reading an environment variable on its own. Aura.View has no config layer and no dependencies, so deciding what "development" means belongs to whatever wires the _View_ up:

```php
<?php
$view->setStrictParent((bool) getenv('APP_DEBUG'));
?>
```

Calling `parent()` when there is no current template at all is a different thing, and throws _Aura\View\Exception_ regardless of strict mode. Template code cannot reach it: if a template is running then it was rendered, and a render always has a frame. It is a guard for code that steps outside the normal path -- most usefully, a _View_ subclass that overrides `render()` without maintaining the render stack, which would otherwise make every `parent()` in the application quietly return `''`. If you override `render()`, push a frame before invoking the template and pop it in a `finally`; see `Aura\View\View::render()`.

Because resolution is per *name*, `parent()` is the seam a modular application needs: the shadowing file and the shadowed file share a name, and neither has to know how many other packages sit in the chain.

### Changing The Template File Extension

By default, each _TemplateRegistry_ will auto-append `.php` to template file names. If the template files end with a different extension, change it using the `setTemplateFileExtension()` method:
Expand Down
183 changes: 183 additions & 0 deletions src/AbstractView.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,28 @@ abstract class AbstractView
*/
private array $section = [];

/**
*
* The stack of in-flight renders, innermost last. Each frame is the
* template name and the search path directory it resolved from (null when
* it came from an explicit map, or from a registry with no search paths).
*
* render() can nest, so parent() needs to know which template is currently
* executing rather than which one was rendered first.
*
* @var list<array{0: string, 1: string|null}>
*
*/
private array $render_stack = [];

/**
*
* Should parent() throw when it has nothing to resume into, instead of
* returning ''?
*
*/
private bool $strict_parent = false;

/**
*
* The template registry currently in use.
Expand Down Expand Up @@ -334,6 +356,41 @@ protected function getTemplate(string $name): \Closure
return $tmpl->bindTo($this, static::class) ?? $tmpl;
}

/**
*
* Gets the search path directory a template name resolves from, or null
* when the registry in use cannot say.
*
*/
protected function getResolvedPath(string $name): ?string
{
$registry = $this->template_registry;

return $registry instanceof SearchPathInterface
? $registry->getResolvedPath($name)
: null;
}

/**
*
* Pushes a render frame; call popRender() when the render finishes.
*
*/
protected function pushRender(string $name, ?string $path): void
{
$this->render_stack[] = [$name, $path];
}

/**
*
* Pops the innermost render frame.
*
*/
protected function popRender(): void
{
array_pop($this->render_stack);
}

/**
*
* Invokes a template and captures its output.
Expand Down Expand Up @@ -372,6 +429,132 @@ protected function captureTemplate(\Closure $template, array $vars): string
return (string) ob_get_clean();
}

/**
*
* Renders the template that the currently-executing one shadows.
*
* Ordinary resolution stops at the first hit, so a template earlier in the
* search path replaces a later one wholesale -- to change one part you
* copy the whole file. `parent()` resumes the search after the directory
* the current template came from, letting the override render the thing it
* overrode:
*
* <?php $this->beginSection('extra') ?>
* ...
* <?php $this->endSection() ?>
* <?= $this->parent() ?>
*
* Returns `''` -- rather than throwing -- when there is nothing further to
* render: the current template shadows nothing, it came from an explicit
* map, or the registry has no search paths. Overriding a template that
* turns out not to shadow anything is a normal state during development.
*
* That forgiveness is also a blind spot: a misconfigured search path
* produces exactly the same `''`, so overrides silently stop composing and
* nothing is raised. `setStrictParent(true)` turns these three cases into
* Exception\ParentNotFound for development and CI.
*
* @param array<string, mixed> $vars Variables for the shadowed template.
*
* @throws Exception when called outside of a render.
*
* @throws Exception\ParentNotFound when there is nothing to resume into
* and strict parent mode is on.
*
*/
protected function parent(array $vars = []): string
{
$frame = end($this->render_stack);

if ($frame === false) {
throw new Exception('parent() called outside of a template render');
}

[$name, $path] = $frame;
$registry = $this->template_registry;

if (! $registry instanceof SearchPathInterface) {
return $this->noParent(
$name,
"the template registry has no search paths to resume along"
);
}

if ($path === null) {
return $this->noParent(
$name,
"it was registered in the map, which has no search path behind it"
);
}

$next = $registry->getNext($name, $path);

if ($next === null) {
return $this->noParent(
$name,
"nothing after '{$path}' in the search paths has that name"
);
}

$template = $next->template->bindTo($this, static::class) ?? $next->template;
$this->pushRender($name, $next->path);

try {
return $this->captureTemplate($template, $vars);
} finally {
$this->popRender();
}
}

/**
*
* Should parent() throw when it has nothing to resume into?
*
* Off by default, so that an override written before the template it
* overrides exists is not an error. Turn it on in development and CI,
* where a search path that resolves to nothing is far more likely to be a
* misconfiguration than an intention -- otherwise it presents as missing
* markup with nothing raised.
*
* This takes a bool rather than reading the environment: Aura.View has no
* config layer and no dependencies, so deciding what "development" means
* belongs to whatever wires the _View_ up.
*
*/
public function setStrictParent(bool $strict_parent): void
{
$this->strict_parent = $strict_parent;
}

/**
*
* Is strict parent mode on?
*
*/
public function isStrictParent(): bool
{
return $this->strict_parent;
}

/**
*
* Answers a parent() call that has nothing to resume into: '' normally,
* an exception naming the template and the reason under strict mode.
*
* @throws Exception\ParentNotFound when strict parent mode is on.
*
*/
protected function noParent(string $name, string $reason): string
{
if (! $this->strict_parent) {
return '';
}

throw new Exception\ParentNotFound(
"parent() found no template to render for '{$name}': {$reason}."
);
}

/**
*
* Sets the content to be used in the layout.
Expand Down
Loading
Loading