| 1 |
<?php |
| 2 |
/** |
| 3 |
* Modules_Manager — discovers, orders, and boots feature modules. |
| 4 |
* |
| 5 |
* @package Templately |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Templately\Core; |
| 9 |
|
| 10 |
use ReflectionClass; |
| 11 |
use Templately\Utils\Base; |
| 12 |
use Throwable; |
| 13 |
|
| 14 |
/** |
| 15 |
* Singleton orchestrator for the module system (spec 004). |
| 16 |
* |
| 17 |
* Scans `modules/{kebab}/module.php` (shipped) AND `modules/developer/{kebab}/module.php` |
| 18 |
* (dev-only, excluded from production zips via `.distignore`) one level deep into a SINGLE |
| 19 |
* discovery pool — no central registration file. Shared name-collision detection and one |
| 20 |
* dependency graph span both roots, so a dev module may depend on a shipped module and on |
| 21 |
* other dev modules. Evaluates each module's `is_active()` then its declarative |
| 22 |
* `get_requirements()`, resolves declared dependencies via topological sort, and |
| 23 |
* instantiates active modules in dependency order. Inactive, unmet-requirement, duplicate, |
| 24 |
* malformed, unresolvable, cyclic, or exception-throwing modules are skipped with a |
| 25 |
* non-fatal diagnostic notice — and, where a name resolves, recorded in |
| 26 |
* {@see get_unavailable_modules()}; the plugin always finishes booting. |
| 27 |
* |
| 28 |
* Also registers an SPL autoloader mapping the per-module namespace |
| 29 |
* `Templately\Modules\{Pascal}\…` to files under `modules/{kebab}/…` (FR-014) — composer |
| 30 |
* PSR-4 covers only `Templately\ → includes/`, so a module's REST/tool sub-classes are |
| 31 |
* loadable ONLY through this map. |
| 32 |
* |
| 33 |
* @see specs/004-core-module-infrastructure |
| 34 |
*/ |
| 35 |
class Modules_Manager extends Base { |
| 36 |
/** |
| 37 |
* Active module instances, keyed by name. |
| 38 |
* |
| 39 |
* @var array<string, Module_Base> |
| 40 |
*/ |
| 41 |
private $modules = []; |
| 42 |
|
| 43 |
/** |
| 44 |
* Modules that were discovered but did not boot, mapped to a human-readable reason: |
| 45 |
* an unmet requirement, a missing/skipped dependency, or a dependency cycle. Only |
| 46 |
* entries whose module name resolved are recorded (a malformed/nameless module has no |
| 47 |
* key to record under). Exposed via {@see get_unavailable_modules()}. |
| 48 |
* |
| 49 |
* @var array<string, string> |
| 50 |
*/ |
| 51 |
private $unavailable = []; |
| 52 |
|
| 53 |
/** |
| 54 |
* Namespace-prefix → base-directory map for the module autoloader (FR-014). |
| 55 |
* |
| 56 |
* @var array<string, string> |
| 57 |
*/ |
| 58 |
private $namespace_map = []; |
| 59 |
|
| 60 |
/** |
| 61 |
* Whether the SPL autoloader has been registered. |
| 62 |
* |
| 63 |
* @var bool |
| 64 |
*/ |
| 65 |
private $autoloader_registered = false; |
| 66 |
|
| 67 |
/** |
| 68 |
* Whether {@see boot()} has run. Guards against a double boot in one request. |
| 69 |
* |
| 70 |
* @var bool |
| 71 |
*/ |
| 72 |
private $booted = false; |
| 73 |
|
| 74 |
/** |
| 75 |
* Discover and boot all modules. |
| 76 |
* |
| 77 |
* @param string|null $modules_dir Absolute path to the modules root. Defaults to |
| 78 |
* `TEMPLATELY_PATH . 'modules'`. Overridable for tests. |
| 79 |
* @return void |
| 80 |
*/ |
| 81 |
public function boot( ?string $modules_dir = null ): void { |
| 82 |
if ( $this->booted ) { |
| 83 |
return; |
| 84 |
} |
| 85 |
$this->booted = true; |
| 86 |
|
| 87 |
if ( null === $modules_dir ) { |
| 88 |
$modules_dir = ( defined( 'TEMPLATELY_PATH' ) ? TEMPLATELY_PATH : '' ) . 'modules'; |
| 89 |
} |
| 90 |
|
| 91 |
$this->register_autoloader(); |
| 92 |
|
| 93 |
$meta = $this->discover( $modules_dir ); |
| 94 |
|
| 95 |
if ( ! empty( $meta ) ) { |
| 96 |
$order = $this->resolve_order( $meta ); |
| 97 |
|
| 98 |
foreach ( $order as $name ) { |
| 99 |
$this->instantiate( $name, $meta[ $name ] ); |
| 100 |
} |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* Fires once after the whole module boot loop completes — even when zero modules |
| 105 |
* were discovered. Consumers can enumerate the active registry / unavailable map. |
| 106 |
* |
| 107 |
* @param Modules_Manager $manager The manager instance. |
| 108 |
*/ |
| 109 |
do_action( 'templately_modules_booted', $this ); |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* Scan both module roots (shipped top level + the dev-only `developer/` sub-root) and |
| 114 |
* collect metadata for every active, uniquely-named, well-formed module whose declared |
| 115 |
* requirements are met. Inactive and unmet-requirement modules are dropped here (before |
| 116 |
* any side effect). |
| 117 |
* |
| 118 |
* @param string $modules_dir Absolute path to the modules root. |
| 119 |
* @return array<string, array{class:string, probe:Module_Base, deps:string[], file:string, dir:string}> |
| 120 |
*/ |
| 121 |
private function discover( string $modules_dir ): array { |
| 122 |
$root = rtrim( $modules_dir, '/\\' ); |
| 123 |
|
| 124 |
// Two roots into ONE pool: shipped `modules/{kebab}/module.php` plus the dev-only |
| 125 |
// `modules/developer/{kebab}/module.php`. The primary glob naturally skips the |
| 126 |
// `developer/` dir itself (it has no module.php one level down from the root). |
| 127 |
// `?: []`, not `(array)`: glob() returns FALSE on error, and `(array) false` |
| 128 |
// is `[ false ]` — which would flow a boolean into basename()/require_once |
| 129 |
// below instead of yielding the empty scan the error case means. |
| 130 |
$files = array_merge( |
| 131 |
glob( $root . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . 'module.php' ) ?: [], |
| 132 |
glob( $root . DIRECTORY_SEPARATOR . 'developer' . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . 'module.php' ) ?: [] |
| 133 |
); |
| 134 |
|
| 135 |
if ( empty( $files ) ) { |
| 136 |
return []; |
| 137 |
} |
| 138 |
|
| 139 |
$meta = []; |
| 140 |
|
| 141 |
foreach ( $files as $file ) { |
| 142 |
$dir = basename( dirname( $file ) ); |
| 143 |
$class = $this->load_module_class( $file ); |
| 144 |
|
| 145 |
if ( null === $class ) { |
| 146 |
$this->notice( sprintf( 'Templately module "%s" skipped: no Module_Base subclass found in %s.', $dir, 'module.php' ) ); |
| 147 |
continue; |
| 148 |
} |
| 149 |
|
| 150 |
// Probe metadata without triggering the constructor's side effects. |
| 151 |
try { |
| 152 |
$probe = ( new ReflectionClass( $class ) )->newInstanceWithoutConstructor(); |
| 153 |
$name = $probe->get_name(); |
| 154 |
$deps = $probe->get_dependencies(); |
| 155 |
} catch ( Throwable $e ) { |
| 156 |
$this->notice( sprintf( 'Templately module "%s" skipped: %s.', $dir, $e->getMessage() ) ); |
| 157 |
continue; |
| 158 |
} |
| 159 |
|
| 160 |
if ( ! is_string( $name ) || '' === $name ) { |
| 161 |
$this->notice( sprintf( 'Templately module in "%s" skipped: get_name() must return a non-empty string.', $dir ) ); |
| 162 |
continue; |
| 163 |
} |
| 164 |
|
| 165 |
if ( isset( $meta[ $name ] ) ) { |
| 166 |
$this->notice( sprintf( 'Templately module "%s" skipped: another module already claims this name.', $name ) ); |
| 167 |
continue; |
| 168 |
} |
| 169 |
|
| 170 |
// Convention guard, notice-only: everything OUTSIDE the manager keys on the |
| 171 |
// DIRECTORY name — the JS asset auto-discovery glob, the dependency gate's |
| 172 |
// kebab↔Pascal mapping, and the load_module_class() repeat-include fallback |
| 173 |
// all assume `get_name() === dirname`. The manager itself keys on the name, |
| 174 |
// so a mismatched module still boots — but it half-works in ways none of |
| 175 |
// those consumers can diagnose, which is why the drift is called out here. |
| 176 |
if ( $name !== $dir ) { |
| 177 |
$this->notice( sprintf( 'Templately module "%s": get_name() does not match its directory "%s"; asset discovery, the dependency gates, and the autoloader fallback all key on the directory name.', $name, $dir ) ); |
| 178 |
} |
| 179 |
|
| 180 |
// Register the namespace → directory mapping for ALL discovered modules — |
| 181 |
// including inactive ones. Loading a class produces no side effects (hooks |
| 182 |
// come from the manager-driven boot, which inactive modules never get), and |
| 183 |
// core code holding a static reference to a module class (e.g. Utils\Http → |
| 184 |
// Auth\REST\Login) must not fatal just because the module is inactive. |
| 185 |
$this->register_namespace( $class, dirname( $file ) ); |
| 186 |
|
| 187 |
if ( ! $probe->is_active() ) { |
| 188 |
continue; // Inactive — zero side effects. |
| 189 |
} |
| 190 |
|
| 191 |
// Requirements gate: evaluated AFTER is_active() passes. The per-module filter |
| 192 |
// runs FIRST, so it can rescue a module (return []) or doom one (add a key) — |
| 193 |
// then a single unmet requirement drops the module and records the reason. |
| 194 |
$requirements = $probe->get_requirements(); |
| 195 |
$requirements = is_array( $requirements ) ? $requirements : []; |
| 196 |
|
| 197 |
/** |
| 198 |
* Filters a module's declared requirements just before they are checked. |
| 199 |
* |
| 200 |
* @param array $requirements The module's declared requirements. |
| 201 |
* @param Module_Base $probe The not-yet-constructed probe instance. |
| 202 |
*/ |
| 203 |
$requirements = apply_filters( "templately_module_requirements_{$name}", $requirements, $probe ); |
| 204 |
$requirements = is_array( $requirements ) ? $requirements : []; |
| 205 |
|
| 206 |
$met = $this->check_requirements( $requirements ); |
| 207 |
if ( true !== $met ) { |
| 208 |
$this->unavailable[ $name ] = $met; |
| 209 |
// NOT notice(): an unmet requirement is a BY-DESIGN outcome, not an anomaly. |
| 210 |
// FR-011's notice list is naming conflict / malformed entry / missing |
| 211 |
// dependency / circular dependency / runtime exception — every one a |
| 212 |
// developer mistake. A module declaring `['multisite' => true]` and then |
| 213 |
// correctly standing down on a single-site install is the gate working, and |
| 214 |
// it happens on EVERY request for the lifetime of the install. Routing it |
| 215 |
// through trigger_error() would render the notice into the response body |
| 216 |
// whenever display_errors is on (the norm for the developer-mode audience), |
| 217 |
// corrupting every REST payload and sending headers early. The designed |
| 218 |
// surface for this outcome is get_unavailable_modules() (see Module_Base:: |
| 219 |
// get_requirements()), which the developer console reads. |
| 220 |
$this->log_unavailable( sprintf( 'Templately module "%s" skipped: %s.', $name, $met ) ); |
| 221 |
continue; |
| 222 |
} |
| 223 |
|
| 224 |
$meta[ $name ] = [ |
| 225 |
'class' => $class, |
| 226 |
'probe' => $probe, |
| 227 |
'deps' => is_array( $deps ) ? $deps : [], |
| 228 |
'file' => $file, |
| 229 |
'dir' => $dir, |
| 230 |
]; |
| 231 |
} |
| 232 |
|
| 233 |
return $meta; |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* Require a module entry file and return the name of the Module_Base subclass it |
| 238 |
* declares, or null if the file is malformed. |
| 239 |
* |
| 240 |
* Uses a declared-classes diff so the entry class may be named anything (avoiding the |
| 241 |
* `module.php` vs `Module.php` autoload-filename clash on case-sensitive filesystems). |
| 242 |
* |
| 243 |
* @param string $file Absolute path to a module.php. |
| 244 |
* @return string|null Fully-qualified class name, or null. |
| 245 |
*/ |
| 246 |
private function load_module_class( string $file ): ?string { |
| 247 |
$before = get_declared_classes(); |
| 248 |
|
| 249 |
try { |
| 250 |
require_once $file; |
| 251 |
} catch ( Throwable $e ) { |
| 252 |
$this->notice( sprintf( 'Templately module entry %s failed to load: %s.', $file, $e->getMessage() ) ); |
| 253 |
return null; |
| 254 |
} |
| 255 |
|
| 256 |
$new = array_diff( get_declared_classes(), $before ); |
| 257 |
|
| 258 |
foreach ( $new as $class ) { |
| 259 |
if ( is_subclass_of( $class, Module_Base::class ) ) { |
| 260 |
return $class; |
| 261 |
} |
| 262 |
} |
| 263 |
|
| 264 |
// The file may have been required earlier in the request (declared-classes diff is |
| 265 |
// empty on a repeat include). Fall back to the naming convention. |
| 266 |
$expected = 'Templately\\Modules\\' . $this->pascal_case( basename( dirname( $file ) ) ) . '\\Module'; |
| 267 |
if ( class_exists( $expected ) && is_subclass_of( $expected, Module_Base::class ) ) { |
| 268 |
return $expected; |
| 269 |
} |
| 270 |
|
| 271 |
return null; |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* Instantiate a single resolved module inside a try/catch (FR-010). Runs the `final` |
| 276 |
* constructor on the already-created probe instance, so `is_active()` was evaluated |
| 277 |
* before any side effect. An uncaught exception removes it from the active registry |
| 278 |
* and emits a diagnostic; the boot continues. |
| 279 |
* |
| 280 |
* @param string $name Module name. |
| 281 |
* @param array $info Discovery metadata for the module. |
| 282 |
* @return void |
| 283 |
*/ |
| 284 |
private function instantiate( string $name, array $info ): void { |
| 285 |
try { |
| 286 |
$instance = $info['probe']; |
| 287 |
$constructor = ( new ReflectionClass( $info['class'] ) )->getConstructor(); |
| 288 |
$constructor->invoke( $instance ); |
| 289 |
|
| 290 |
$this->modules[ $name ] = $instance; |
| 291 |
|
| 292 |
/** |
| 293 |
* Fires once for each module that successfully boots, in dependency order. |
| 294 |
* |
| 295 |
* @param string $name The module name. |
| 296 |
* @param Module_Base $instance The booted module instance. |
| 297 |
*/ |
| 298 |
do_action( 'templately_module_booted', $name, $instance ); |
| 299 |
} catch ( Throwable $e ) { |
| 300 |
unset( $this->modules[ $name ] ); |
| 301 |
$this->notice( sprintf( 'Templately module "%s" removed from the active registry: %s.', $name, $e->getMessage() ) ); |
| 302 |
} |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Compute the boot order via topological sort, skipping modules with an inactive / |
| 307 |
* absent dependency (FR-005) and every module involved in a dependency cycle (FR-006). |
| 308 |
* |
| 309 |
* @param array<string, array> $meta Discovery metadata keyed by module name. |
| 310 |
* @return string[] Module names in dependency order (dependencies first). |
| 311 |
*/ |
| 312 |
private function resolve_order( array $meta ): array { |
| 313 |
$skipped = []; |
| 314 |
|
| 315 |
foreach ( $this->find_cycle_nodes( $meta ) as $name ) { |
| 316 |
$skipped[ $name ] = 'circular dependency'; |
| 317 |
} |
| 318 |
|
| 319 |
// Modules already dropped by the requirements gate. Depending on one of these is a |
| 320 |
// BY-DESIGN outcome (the environment simply lacks the feature), so the skip it |
| 321 |
// cascades is logged rather than raised as an FR-011 anomaly notice — see |
| 322 |
// log_unavailable(). At this point $this->unavailable holds gate entries only. |
| 323 |
$gated = $this->unavailable; |
| 324 |
|
| 325 |
/** @var array<string,bool> Names whose skip is by design, not a developer mistake. */ |
| 326 |
$by_design = []; |
| 327 |
|
| 328 |
// Propagate skips: a module whose dependency is absent or skipped cannot boot. |
| 329 |
do { |
| 330 |
$changed = false; |
| 331 |
foreach ( $meta as $name => $info ) { |
| 332 |
if ( isset( $skipped[ $name ] ) ) { |
| 333 |
continue; |
| 334 |
} |
| 335 |
foreach ( $info['deps'] as $dep ) { |
| 336 |
if ( isset( $gated[ $dep ] ) ) { |
| 337 |
$skipped[ $name ] = sprintf( "dependency '%s' is unavailable: %s", $dep, $gated[ $dep ] ); |
| 338 |
$by_design[ $name ] = true; |
| 339 |
} elseif ( ! isset( $meta[ $dep ] ) ) { |
| 340 |
$skipped[ $name ] = sprintf( "missing dependency '%s'", $dep ); |
| 341 |
} elseif ( isset( $skipped[ $dep ] ) ) { |
| 342 |
$skipped[ $name ] = sprintf( "dependency '%s' was skipped", $dep ); |
| 343 |
$by_design[ $name ] = isset( $by_design[ $dep ] ); |
| 344 |
} else { |
| 345 |
continue; |
| 346 |
} |
| 347 |
$changed = true; |
| 348 |
break; |
| 349 |
} |
| 350 |
} |
| 351 |
} while ( $changed ); |
| 352 |
|
| 353 |
foreach ( $skipped as $name => $reason ) { |
| 354 |
$this->unavailable[ $name ] = $reason; |
| 355 |
$message = sprintf( 'Templately module "%s" skipped: %s.', $name, $reason ); |
| 356 |
|
| 357 |
if ( isset( $by_design[ $name ] ) && $by_design[ $name ] ) { |
| 358 |
$this->log_unavailable( $message ); |
| 359 |
} else { |
| 360 |
$this->notice( $message ); |
| 361 |
} |
| 362 |
} |
| 363 |
|
| 364 |
return $this->topological_order( $meta, $skipped ); |
| 365 |
} |
| 366 |
|
| 367 |
/** |
| 368 |
* Return the names of all modules involved in any dependency cycle. |
| 369 |
* |
| 370 |
* @param array<string, array> $meta Discovery metadata. |
| 371 |
* @return string[] |
| 372 |
*/ |
| 373 |
private function find_cycle_nodes( array $meta ): array { |
| 374 |
$color = []; // 0 = unvisited, 1 = on stack, 2 = done. |
| 375 |
$stack = []; |
| 376 |
$cycle = []; |
| 377 |
|
| 378 |
$dfs = function ( string $u ) use ( &$dfs, &$meta, &$color, &$stack, &$cycle ): void { |
| 379 |
$color[ $u ] = 1; |
| 380 |
$stack[] = $u; |
| 381 |
|
| 382 |
foreach ( $meta[ $u ]['deps'] as $v ) { |
| 383 |
if ( ! isset( $meta[ $v ] ) ) { |
| 384 |
continue; // Absent dep — handled by skip propagation, not a cycle. |
| 385 |
} |
| 386 |
$state = $color[ $v ] ?? 0; |
| 387 |
if ( 1 === $state ) { |
| 388 |
$idx = array_search( $v, $stack, true ); |
| 389 |
foreach ( array_slice( $stack, $idx ) as $node ) { |
| 390 |
$cycle[ $node ] = true; |
| 391 |
} |
| 392 |
} elseif ( 0 === $state ) { |
| 393 |
$dfs( $v ); |
| 394 |
} |
| 395 |
} |
| 396 |
|
| 397 |
array_pop( $stack ); |
| 398 |
$color[ $u ] = 2; |
| 399 |
}; |
| 400 |
|
| 401 |
foreach ( array_keys( $meta ) as $u ) { |
| 402 |
if ( 0 === ( $color[ $u ] ?? 0 ) ) { |
| 403 |
$dfs( $u ); |
| 404 |
} |
| 405 |
} |
| 406 |
|
| 407 |
return array_keys( $cycle ); |
| 408 |
} |
| 409 |
|
| 410 |
/** |
| 411 |
* Post-order DFS topological sort over the surviving (non-skipped) modules. |
| 412 |
* |
| 413 |
* @param array<string, array> $meta Discovery metadata. |
| 414 |
* @param array<string, string> $skipped Skipped module names → reason. |
| 415 |
* @return string[] Dependencies precede their dependents. |
| 416 |
*/ |
| 417 |
private function topological_order( array $meta, array $skipped ): array { |
| 418 |
$ordered = []; |
| 419 |
$visited = []; |
| 420 |
|
| 421 |
$visit = function ( string $u ) use ( &$visit, &$meta, &$skipped, &$visited, &$ordered ): void { |
| 422 |
if ( isset( $visited[ $u ] ) || isset( $skipped[ $u ] ) ) { |
| 423 |
return; |
| 424 |
} |
| 425 |
$visited[ $u ] = true; |
| 426 |
|
| 427 |
foreach ( $meta[ $u ]['deps'] as $v ) { |
| 428 |
if ( isset( $meta[ $v ] ) && ! isset( $skipped[ $v ] ) ) { |
| 429 |
$visit( $v ); |
| 430 |
} |
| 431 |
} |
| 432 |
|
| 433 |
$ordered[] = $u; |
| 434 |
}; |
| 435 |
|
| 436 |
foreach ( array_keys( $meta ) as $u ) { |
| 437 |
$visit( $u ); |
| 438 |
} |
| 439 |
|
| 440 |
return $ordered; |
| 441 |
} |
| 442 |
|
| 443 |
/** |
| 444 |
* Retrieve an active module instance by name (FR-013). Returns null when the module is |
| 445 |
* absent, inactive, or not yet booted — safe for optional/post-boot lookups, but no |
| 446 |
* substitute for declaring a dependency via `get_dependencies()`. |
| 447 |
* |
| 448 |
* @param string $name Module name. |
| 449 |
* @return Module_Base|null |
| 450 |
*/ |
| 451 |
public function get_module( string $name ): ?Module_Base { |
| 452 |
return $this->modules[ $name ] ?? null; |
| 453 |
} |
| 454 |
|
| 455 |
/** |
| 456 |
* The names of all currently active modules (FR-008). |
| 457 |
* |
| 458 |
* @return string[] |
| 459 |
*/ |
| 460 |
public function get_active_modules(): array { |
| 461 |
return array_keys( $this->modules ); |
| 462 |
} |
| 463 |
|
| 464 |
/** |
| 465 |
* Modules that were discovered but did not boot, mapped to a human-readable reason |
| 466 |
* (unmet requirement, missing/skipped dependency, or dependency cycle). Complements |
| 467 |
* {@see get_active_modules()} for diagnostics / a developer surface. |
| 468 |
* |
| 469 |
* @return array<string, string> Module name → reason. |
| 470 |
*/ |
| 471 |
public function get_unavailable_modules(): array { |
| 472 |
return $this->unavailable; |
| 473 |
} |
| 474 |
|
| 475 |
/** |
| 476 |
* Declared-but-unmet sub-feature gates per ACTIVE module (spec 053). |
| 477 |
* |
| 478 |
* Complements {@see get_unavailable_modules()}: that inventory lists |
| 479 |
* modules that did not boot at all, this one lists booted modules whose |
| 480 |
* gated enhancements are currently off. Resolved lazily on call — never |
| 481 |
* during discovery — so modules may register their own capability keys in |
| 482 |
* `init_hooks()` before anything is checked. |
| 483 |
* |
| 484 |
* @return array<string, array<int, array{gate:string, capability:string, reason:string}>> |
| 485 |
*/ |
| 486 |
public function get_unmet_gates(): array { |
| 487 |
$capabilities = Capabilities::get_instance(); |
| 488 |
$unmet = []; |
| 489 |
|
| 490 |
foreach ( $this->modules as $name => $module ) { |
| 491 |
foreach ( $module->get_capability_gates() as $gate => $capability ) { |
| 492 |
if ( $capabilities->has( $capability ) ) { |
| 493 |
continue; |
| 494 |
} |
| 495 |
|
| 496 |
$explanation = $capabilities->explain( $capability ); |
| 497 |
|
| 498 |
// A gate map is `array<string,string>` by contract, but the return |
| 499 |
// type only constrains the container — a module CAN hand back a |
| 500 |
// non-string value. `sprintf( '%s' )` would then raise "Array to |
| 501 |
// string conversion", so the label is built defensively; the |
| 502 |
// resolver has already answered unavailable and warned. |
| 503 |
$label = is_scalar( $capability ) ? (string) $capability : gettype( $capability ); |
| 504 |
|
| 505 |
$unmet[ $name ][] = [ |
| 506 |
'gate' => $gate, |
| 507 |
'capability' => $capability, |
| 508 |
'reason' => sprintf( 'capability "%s" unavailable (decided by %s)', $label, $explanation['decided_by'] ), |
| 509 |
]; |
| 510 |
} |
| 511 |
} |
| 512 |
|
| 513 |
return $unmet; |
| 514 |
} |
| 515 |
|
| 516 |
/** |
| 517 |
* Register the module SPL autoloader once (FR-014). |
| 518 |
* |
| 519 |
* @return void |
| 520 |
*/ |
| 521 |
private function register_autoloader(): void { |
| 522 |
if ( $this->autoloader_registered ) { |
| 523 |
return; |
| 524 |
} |
| 525 |
$this->autoloader_registered = true; |
| 526 |
|
| 527 |
spl_autoload_register( [ $this, 'autoload' ] ); |
| 528 |
} |
| 529 |
|
| 530 |
/** |
| 531 |
* Map a discovered module's namespace prefix to its directory for the autoloader. |
| 532 |
* |
| 533 |
* @param string $class Fully-qualified entry class name. |
| 534 |
* @param string $dir Absolute module directory (dirname of module.php). |
| 535 |
* @return void |
| 536 |
*/ |
| 537 |
private function register_namespace( string $class, string $dir ): void { |
| 538 |
$namespace = ( new ReflectionClass( $class ) )->getNamespaceName(); |
| 539 |
if ( '' === $namespace ) { |
| 540 |
return; |
| 541 |
} |
| 542 |
|
| 543 |
$prefix = $namespace . '\\'; |
| 544 |
$base = rtrim( $dir, '/\\' ) . DIRECTORY_SEPARATOR; |
| 545 |
|
| 546 |
// Two modules declaring the SAME namespace (the copy-a-module-and-rename-the- |
| 547 |
// directory mistake) would otherwise silently overwrite this entry, and every |
| 548 |
// sub-class of BOTH modules would autoload from whichever directory registered |
| 549 |
// last — a wrong-file failure with no visible cause. Keep the first mapping and |
| 550 |
// say so, mirroring the name-collision rule above. |
| 551 |
if ( isset( $this->namespace_map[ $prefix ] ) && $this->namespace_map[ $prefix ] !== $base ) { |
| 552 |
$this->notice( sprintf( 'Templately module namespace "%s" is already mapped to %s; ignoring the duplicate declaration in %s.', $namespace, $this->namespace_map[ $prefix ], $base ) ); |
| 553 |
return; |
| 554 |
} |
| 555 |
|
| 556 |
$this->namespace_map[ $prefix ] = $base; |
| 557 |
} |
| 558 |
|
| 559 |
/** |
| 560 |
* SPL autoloader for module sub-classes (FR-014). Maps |
| 561 |
* `Templately\Modules\{Pascal}\Sub\Class` → `modules/{kebab}/Sub/Class.php`. |
| 562 |
* |
| 563 |
* @param string $class Fully-qualified class name being loaded. |
| 564 |
* @return void |
| 565 |
*/ |
| 566 |
public function autoload( string $class ): void { |
| 567 |
foreach ( $this->namespace_map as $prefix => $base_dir ) { |
| 568 |
if ( 0 !== strncmp( $class, $prefix, strlen( $prefix ) ) ) { |
| 569 |
continue; |
| 570 |
} |
| 571 |
$relative = substr( $class, strlen( $prefix ) ); |
| 572 |
$file = $base_dir . str_replace( '\\', DIRECTORY_SEPARATOR, $relative ) . '.php'; |
| 573 |
if ( is_readable( $file ) ) { |
| 574 |
require_once $file; |
| 575 |
} |
| 576 |
return; |
| 577 |
} |
| 578 |
} |
| 579 |
|
| 580 |
/** |
| 581 |
* Convert a kebab-case directory name to PascalCase (`template-browsing` → `TemplateBrowsing`). |
| 582 |
* |
| 583 |
* @param string $kebab Kebab-case name. |
| 584 |
* @return string |
| 585 |
*/ |
| 586 |
private function pascal_case( string $kebab ): string { |
| 587 |
return str_replace( ' ', '', ucwords( str_replace( [ '-', '_' ], ' ', $kebab ) ) ); |
| 588 |
} |
| 589 |
|
| 590 |
/** |
| 591 |
* Verify a module's declared requirements against the running environment. |
| 592 |
* |
| 593 |
* Returns `true` when every declared requirement is met, or a single human-readable |
| 594 |
* reason string for the FIRST unmet one (e.g. "requires PHP >= 7.4 (running 7.2.34)"). |
| 595 |
* All keys are optional; see {@see Module_Base::get_requirements()} for the contract. |
| 596 |
* Kept side-effect-free so it is safe to call during discovery on a probe instance. |
| 597 |
* |
| 598 |
* @param array $requirements Declared (and already filtered) requirements. |
| 599 |
* @return true|string |
| 600 |
*/ |
| 601 |
private function check_requirements( array $requirements ) { |
| 602 |
if ( isset( $requirements['php'] ) && version_compare( PHP_VERSION, (string) $requirements['php'], '<' ) ) { |
| 603 |
return sprintf( 'requires PHP >= %s (running %s)', $requirements['php'], PHP_VERSION ); |
| 604 |
} |
| 605 |
|
| 606 |
if ( isset( $requirements['wp'] ) ) { |
| 607 |
$wp_version = get_bloginfo( 'version' ); |
| 608 |
if ( '' === $wp_version && isset( $GLOBALS['wp_version'] ) ) { |
| 609 |
$wp_version = $GLOBALS['wp_version']; |
| 610 |
} |
| 611 |
if ( version_compare( (string) $wp_version, (string) $requirements['wp'], '<' ) ) { |
| 612 |
return sprintf( 'requires WordPress >= %s (running %s)', $requirements['wp'], $wp_version ); |
| 613 |
} |
| 614 |
} |
| 615 |
|
| 616 |
if ( ! empty( $requirements['classes'] ) ) { |
| 617 |
foreach ( (array) $requirements['classes'] as $class ) { |
| 618 |
if ( ! class_exists( $class ) && ! interface_exists( $class ) ) { |
| 619 |
return sprintf( 'requires the class %s', $class ); |
| 620 |
} |
| 621 |
} |
| 622 |
} |
| 623 |
|
| 624 |
if ( ! empty( $requirements['functions'] ) ) { |
| 625 |
foreach ( (array) $requirements['functions'] as $function ) { |
| 626 |
if ( ! function_exists( $function ) ) { |
| 627 |
return sprintf( 'requires the function %s()', $function ); |
| 628 |
} |
| 629 |
} |
| 630 |
} |
| 631 |
|
| 632 |
if ( ! empty( $requirements['plugins'] ) ) { |
| 633 |
// is_plugin_active() lives in wp-admin/includes and is NOT loaded on plugins_loaded, |
| 634 |
// so read the option directly (plus network-active plugins on multisite). |
| 635 |
$active = (array) get_option( 'active_plugins', [] ); |
| 636 |
if ( is_multisite() ) { |
| 637 |
$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) ); |
| 638 |
} |
| 639 |
foreach ( (array) $requirements['plugins'] as $plugin ) { |
| 640 |
if ( ! in_array( $plugin, $active, true ) ) { |
| 641 |
return sprintf( 'requires the plugin %s to be active', $plugin ); |
| 642 |
} |
| 643 |
} |
| 644 |
} |
| 645 |
|
| 646 |
if ( isset( $requirements['multisite'] ) ) { |
| 647 |
$needs_multisite = (bool) $requirements['multisite']; |
| 648 |
if ( $needs_multisite !== is_multisite() ) { |
| 649 |
return $needs_multisite ? 'requires multisite' : 'requires a single-site install'; |
| 650 |
} |
| 651 |
} |
| 652 |
|
| 653 |
return true; |
| 654 |
} |
| 655 |
|
| 656 |
/** |
| 657 |
* Emit a non-fatal diagnostic notice for a skipped module (FR-011). |
| 658 |
* |
| 659 |
* @param string $message Human-readable reason. |
| 660 |
* @return void |
| 661 |
*/ |
| 662 |
private function notice( string $message ): void { |
| 663 |
trigger_error( esc_html( $message ), E_USER_NOTICE ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error |
| 664 |
|
| 665 |
// Templately's dedicated log file, not debug.log (WP_DEBUG_LOG-gated inside). |
| 666 |
\Templately\Utils\Helper::log( $message, 'modules', 'warning' ); |
| 667 |
} |
| 668 |
|
| 669 |
/** |
| 670 |
* Record a BY-DESIGN module skip (an unmet declarative requirement). |
| 671 |
* |
| 672 |
* Deliberately log-only — the counterpart to {@see notice()} for outcomes that are |
| 673 |
* expected rather than anomalous. `trigger_error()` writes into the response body when |
| 674 |
* `display_errors` is on, so using it for a condition that recurs on every request |
| 675 |
* (e.g. a multisite-only module on a single-site install) would corrupt every REST |
| 676 |
* payload and send headers before WordPress can. The machine-readable surface for |
| 677 |
* these skips is {@see get_unavailable_modules()}. |
| 678 |
* |
| 679 |
* @param string $message Human-readable reason. |
| 680 |
* @return void |
| 681 |
*/ |
| 682 |
private function log_unavailable( string $message ): void { |
| 683 |
// Templately's dedicated log file, not debug.log (WP_DEBUG_LOG-gated inside). |
| 684 |
\Templately\Utils\Helper::log( $message, 'modules', 'info' ); |
| 685 |
} |
| 686 |
} |
| 687 |
|