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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tools › Pollora's Modules card and `pollora:status` say where module states are stored, and the card links to the Modules view
- Update checks for modules installed by Composer: a module whose path is a Composer package's install path (placed in `Modules/` by the skeleton's `installer-paths` rule, or scanned in `vendor/`) carries that package's version, whatever its type; a local module has none and is never checked. The latest release comes from the repository the project's `composer.json` declares for it — a `composer` repository (Private Packagist, Satis: `{url}/p2/{package}.json`), a GitHub `vcs` repository (latest release, else highest tag; `MODULES_GITHUB_TOKEN` for a private one), or Packagist — is cached per package for 12 hours (1 hour when the source does not answer), and is fetched by a daily WP-Cron task or `pollora:module:outdated`, never during a front-end request. Plugins › Modules shows "1.3.0 · 1.4.0 available" with the `composer update` command, Site Health tests "Pollora modules are up to date", and `pollora:status` (`--json` too) gives each module's version and latest; a `dev-*` build is never reported outdated
- `pollora:make:block --module=<name>` creates a block in a module's `resources/views/blocks`, named after the module in kebab case (`blocks-demo/hero`), like the seven generators that already took `--module`
- `AnsweringTemplate` says which template the hierarchy answered a request with: template file, conditional tag, Blade view, whether it fell back to `index`. It stays empty when `Route::wp()` or a Laravel route answered, and works without `WP_DEBUG`, unlike the template marker
- `Pollora\WordPress\Events\WordPressBooting` is dispatched right before `wp-settings.php` loads: the last moment to set something up before WordPress builds its globals. Listen to it from a provider's `register()`
- `DiscoveryCacheManager::scans()` says, for each location, whether its structures came from this process, the persistent cache or a scan, with time and count. A cold and a warm persistent cache used to both count as a miss
- `Pollora\BlockBinding\Domain\Events\BindingResolved` is dispatched for each block binding resolved, with its source, field, post, time and whether the cache answered; only when something listens
- `AssetManager::containers()` lists every asset container by name: the theme's, plugins', modules'

### Fixed
- Tools › Pollora and `pollora:status` reported 0 modules when one `module.json` had no `priority`: nwidart's `getPriority()` is typed `string` and threw
Expand Down
11 changes: 11 additions & 0 deletions src/Asset/Application/Services/AssetManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ public function getContainer(string $name): ?AssetContainer
return $this->containers[$name] ?? null;
}

/**
* Every asset container, keyed by name: the theme's, each plugin's and
* module's, for tools that list where a page's assets come from.
*
* @return array<string, AssetContainer>
*/
public function containers(): array
{
return $this->containers;
}

/**
* Sets the default asset container.
*
Expand Down
36 changes: 35 additions & 1 deletion src/BlockBinding/Application/Services/BindingResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Pollora\BlockBinding\Domain\Contracts\ContentVisibilityInterface;
use Pollora\BlockBinding\Domain\Contracts\ValuePresenterInterface;
use Pollora\BlockBinding\Domain\Enums\BindingFieldType;
use Pollora\BlockBinding\Domain\Events\BindingResolved;
use Pollora\BlockBinding\Domain\Models\BindingContext;
use Pollora\BlockBinding\Domain\Models\BindingFieldDefinition;
use Pollora\BlockBinding\Domain\Models\BindingSource;
Expand Down Expand Up @@ -112,12 +113,45 @@ private function resolveIn(BindingSource $source, array $args, array $blockConte
$key = $this->cacheKey($source, $context, $attributeSource);

if (array_key_exists($key, $this->resolved)) {
$this->announce($source, $field, $context, 0.0, true, $this->resolved[$key] !== null);

return $this->resolved[$key];
}

$start = hrtime(true);
$value = $this->call($source, $field, $context);
$milliseconds = (hrtime(true) - $start) / 1_000_000;

$this->resolved[$key] = $this->present($value, $field->type, $attributeSource);
$this->announce($source, $field, $context, $milliseconds, false, $this->resolved[$key] !== null);

return $this->resolved[$key];
}

/**
* Tell debugging tools what was resolved, when one is listening.
*/
private function announce(BindingSource $source, BindingFieldDefinition $field, BindingContext $context, float $milliseconds, bool $cached, bool $hasValue): void
{
if (! $this->container->bound('events')) {
return;
}

$events = $this->container->make('events');

if (! $events->hasListeners(BindingResolved::class)) {
return;
}

return $this->resolved[$key] = $this->present($value, $field->type, $attributeSource);
$events->dispatch(new BindingResolved(
source: $source->name,
field: $field->name,
attribute: $context->attribute,
postId: $context->postId,
milliseconds: round($milliseconds, 3),
cached: $cached,
hasValue: $hasValue,
));
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/BlockBinding/Domain/Events/BindingResolved.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Pollora\BlockBinding\Domain\Events;

/**
* A block binding gave a value to a block attribute.
*
* For debugging tools: which source answered which attribute, on which post,
* and what it cost. Dispatched only when something listens, so a site
* without a listener pays nothing for it.
*/
final readonly class BindingResolved
{
/**
* @param string $source The source's name (`acme/reading-time`)
* @param string $field The field asked for, empty for an invokable source
* @param string $attribute The block attribute it fills
* @param int|null $postId The post the block was rendered for
* @param float $milliseconds Time spent in the source, 0 when the value came from this request's cache
* @param bool $cached Whether the same binding had already been resolved in this request
* @param bool $hasValue Whether the source returned a value (false: the block keeps its own)
*/
public function __construct(
public string $source,
public string $field,
public string $attribute,
public ?int $postId,
public float $milliseconds,
public bool $cached,
public bool $hasValue,
) {}
}
41 changes: 41 additions & 0 deletions src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class DiscoveryCacheManager
*/
private readonly ?DiscoverCacheDriver $cacheDriver;

/**
* Where each location's structures came from in this process, in order.
*
* @var list<array{path: string, source: 'memory'|'cache'|'disk', milliseconds: float, structures: int}>
*/
private array $scans = [];

public function __construct(
private readonly Container $container,
private readonly DebugDetectorInterface $debugDetector
Expand All @@ -60,25 +67,59 @@ public function getStructuresForLocation(DiscoveryLocationInterface $location, D

if (isset(self::$structuresCache[$cacheId])) {
$context->recordCacheHit();
$this->recordScan($location, 'memory', 0.0, count(self::$structuresCache[$cacheId]));

return self::$structuresCache[$cacheId];
}

$context->recordCacheMiss();

// Asked before reading: once the discoverer has run, a cold cache has
// been filled and looks exactly like a warm one.
$fromCache = $this->shouldUseCache() && $this->cacheDriver->has($cacheId);

$discover = $this->createSpatieDiscoverer($location, $cacheId);

$startedAt = microtime(true);
$structures = $discover->get();
$elapsed = (microtime(true) - $startedAt) * 1000;

self::$structuresCache[$cacheId] = $structures;
$this->recordScan($location, $fromCache ? 'cache' : 'disk', $elapsed, count($structures));

$this->warnIfSlow($location, $elapsed, count($structures));

return $structures;
}

/**
* Where each location's structures came from, and what it cost.
*
* `memory` is this process's own copy, `cache` the persistent cache, `disk`
* a real scan — the one that costs, and the only one in debug mode, where
* the persistent cache is off. The context's hit and miss counters only
* know about the first of the three.
*
* @return list<array{path: string, source: 'memory'|'cache'|'disk', milliseconds: float, structures: int}>
*/
public function scans(): array
{
return $this->scans;
}

/**
* @param 'memory'|'cache'|'disk' $source
*/
private function recordScan(DiscoveryLocationInterface $location, string $source, float $milliseconds, int $structures): void
{
$this->scans[] = [
'path' => $location->getPath(),
'source' => $source,
'milliseconds' => round($milliseconds, 2),
'structures' => $structures,
];
}

/**
* Say so when one location costs more than it can possibly be worth.
*
Expand Down
37 changes: 37 additions & 0 deletions src/Route/Application/Services/AnsweringTemplate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\Application\Services;

use Pollora\Route\Domain\Models\TemplateResolution;

/**
* Remembers which template answered the current request.
*
* Filled by the frontend controller, read by debugging tools. A request that a
* `Route::wp()` route or a Laravel route answered never reaches the template
* hierarchy, so it leaves this empty — which is how a reader tells them apart.
*/
final class AnsweringTemplate
{
private ?TemplateResolution $resolution = null;

public function record(TemplateResolution $resolution): void
{
$this->resolution = $resolution;
}

public function resolution(): ?TemplateResolution
{
return $this->resolution;
}

/**
* Forget the last resolution, for workers that serve several requests.
*/
public function reset(): void
{
$this->resolution = null;
}
}
20 changes: 20 additions & 0 deletions src/Route/Domain/Enums/TemplateOutcome.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\Domain\Enums;

/**
* How the template hierarchy ended up answering a request.
*/
enum TemplateOutcome: string
{
/** A Blade view rendered the page. */
case View = 'view';

/** A PHP file was included, as a block theme's template-canvas.php is. */
case File = 'file';

/** Nothing in the hierarchy could answer, so the 404 page did. */
case NotFound = 'not_found';
}
33 changes: 33 additions & 0 deletions src/Route/Domain/Models/TemplateResolution.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\Domain\Models;

use Pollora\Route\Domain\Enums\TemplateOutcome;

/**
* What the template hierarchy settled on for one request.
*
* The frontend controller works this out and then throws it away; keeping it
* lets tooling say which template answered without running the hierarchy a
* second time — and a second run could disagree, since `template_include`
* filters are free to look at anything.
*/
final readonly class TemplateResolution
{
/**
* @param string $template The file `template_include` returned, empty when themes are off
* @param string|null $condition The conditional tag that picked the template (`is_single`…), null for the index fallback
* @param string|null $view The Blade view name, when the template maps to one
* @param bool $usedIndexFallback Whether no specific template matched and the index answered
* @param TemplateOutcome $outcome What finally rendered the response
*/
public function __construct(
public string $template,
public ?string $condition,
public ?string $view,
public bool $usedIndexFallback,
public TemplateOutcome $outcome,
) {}
}
5 changes: 5 additions & 0 deletions src/Route/Infrastructure/Providers/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Pollora\Route\Application\Services\AnsweringTemplate;
use Pollora\Route\Application\UseCases\BindWordPressParametersUseCase;
use Pollora\Route\Application\UseCases\RegisterWordPressTypesUseCase;
use Pollora\Route\Domain\Contracts\ConditionResolverInterface;
Expand Down Expand Up @@ -87,6 +88,10 @@ public function boot(): void
*/
private function registerDomainContracts(): void
{
// Which template answered: written by the frontend controller, read
// by debugging tools later in the same request
$this->app->singleton(AnsweringTemplate::class);

// Register the WordPress type resolver
$this->app->singleton(WordPressTypeResolverInterface::class, WordPressTypeResolver::class);

Expand Down
Loading
Loading