PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / includes / Core / Modules_Manager.php

Modules_Manager.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at includes/Core/Modules_Manager.php

687 lines 24.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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