| 1 |
<?php |
| 2 |
/** |
| 3 |
* Module_Base — the contract every feature module extends. |
| 4 |
* |
| 5 |
* @package Templately |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Templately\Core; |
| 9 |
|
| 10 |
/** |
| 11 |
* Abstract base class for every auto-discovered feature module. |
| 12 |
* |
| 13 |
* A module lives in `modules/{kebab-name}/module.php` and declares a single class |
| 14 |
* extending this base. The {@see Modules_Manager} discovers, orders, and instantiates |
| 15 |
* these classes. The constructor is `final` so the boot lifecycle cannot be bypassed — |
| 16 |
* override the lifecycle methods (`init_hooks()`, `register_rest_routes()`, |
| 17 |
* `enqueue_assets()`) instead of the constructor. |
| 18 |
* |
| 19 |
* Two entry points with strictly separated responsibilities: |
| 20 |
* - PHP (`module.php`, this class): WordPress hooks, REST endpoint registration, PHP |
| 21 |
* asset enqueuing, and sub-component registration. |
| 22 |
* - JS (`assets/js/index.ts`): SPA route registration via `addFilter('templately.routes')` |
| 23 |
* and Redux reducer attachment. PHP `add_filter('templately.routes')` never reaches it — |
| 24 |
* the two hook systems are independent. |
| 25 |
* |
| 26 |
* @see specs/004-core-module-infrastructure |
| 27 |
*/ |
| 28 |
abstract class Module_Base { |
| 29 |
/** |
| 30 |
* Named internal sub-components, keyed by id. |
| 31 |
* |
| 32 |
* @var array<string, mixed> |
| 33 |
*/ |
| 34 |
private $components = []; |
| 35 |
|
| 36 |
/** |
| 37 |
* Boot the module. |
| 38 |
* |
| 39 |
* Declared `final`: runs `init_hooks()` during construction and defers the REST / |
| 40 |
* asset lifecycle to their proper hooks. `register_rest_routes()` is intentionally |
| 41 |
* NOT called here — `register_rest_route()` only works on `rest_api_init`; calling it |
| 42 |
* on `plugins_loaded` silently drops the route with a `_doing_it_wrong` notice |
| 43 |
* (constitution §VIII). The manager wraps this constructor in a try/catch, so an |
| 44 |
* exception thrown from `init_hooks()` removes the module from the active registry |
| 45 |
* without aborting the boot of other modules (FR-010). |
| 46 |
*/ |
| 47 |
final public function __construct() { |
| 48 |
$this->init_hooks(); |
| 49 |
|
| 50 |
add_action( 'rest_api_init', [ $this, 'boot_rest_routes' ] ); |
| 51 |
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* Unique kebab-case module name. Uniqueness is FR-003 (a duplicate is skipped); |
| 56 |
* equality with the module's DIRECTORY name is the convention everything outside |
| 57 |
* the manager keys on (JS asset discovery, the dependency gates, the autoloader |
| 58 |
* fallback) — the manager boots a mismatched module but emits a notice. |
| 59 |
* |
| 60 |
* @return string |
| 61 |
*/ |
| 62 |
abstract public function get_name(): string; |
| 63 |
|
| 64 |
/** |
| 65 |
* Whether the module should boot. |
| 66 |
* |
| 67 |
* Evaluated by the manager BEFORE the real instantiation. Returning `false` skips the |
| 68 |
* module entirely — no hooks, no REST endpoints, no assets (FR-002). MUST be safe to |
| 69 |
* call on a not-yet-constructed instance (no dependence on constructor state). |
| 70 |
* |
| 71 |
* @return bool |
| 72 |
*/ |
| 73 |
public function is_active(): bool { |
| 74 |
return true; |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Whether a module HELD BACK from the current release may boot. |
| 79 |
* |
| 80 |
* Some modules are complete and tested but not part of the release being cut — |
| 81 |
* they ship in the zip, dormant. Each of them answers `is_active()` with this, so |
| 82 |
* the manager skips it entirely: no hooks, no REST routes, no assets, and the SPA |
| 83 |
* pieces that key off what the module localizes never appear either. |
| 84 |
* |
| 85 |
* Off by default, everywhere. Turn them back on for development or QA with |
| 86 |
* `define( 'TEMPLATELY_ENABLE_DEFERRED_MODULES', true );` in wp-config.php, or per |
| 87 |
* module through the filter. The test bootstrap opts in so the dormant code keeps |
| 88 |
* its coverage. Releasing a module means deleting its `is_active()` override, not |
| 89 |
* flipping the default here. |
| 90 |
* |
| 91 |
* @param string $name The module's own name, for the filter. |
| 92 |
* @return bool |
| 93 |
*/ |
| 94 |
public static function deferred_module_enabled( string $name ): bool { |
| 95 |
$enabled = defined( 'TEMPLATELY_ENABLE_DEFERRED_MODULES' ) && TEMPLATELY_ENABLE_DEFERRED_MODULES; |
| 96 |
|
| 97 |
/** |
| 98 |
* Filters whether a module held back from the release may boot. |
| 99 |
* |
| 100 |
* @param bool $enabled The constant's answer (false when it is not defined). |
| 101 |
* @param string $name The module name, e.g. 'mcp-server'. |
| 102 |
*/ |
| 103 |
return (bool) apply_filters( 'templately_deferred_module_enabled', $enabled, $name ); |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Module names this module depends on; drives topological boot ordering (FR-004..006). |
| 108 |
* |
| 109 |
* MUST be safe to call on a not-yet-constructed instance. |
| 110 |
* |
| 111 |
* @return string[] |
| 112 |
*/ |
| 113 |
public function get_dependencies(): array { |
| 114 |
return []; |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Declarative environment requirements the manager verifies BEFORE instantiation. |
| 119 |
* |
| 120 |
* Evaluated by the manager during discovery, AFTER {@see is_active()} passes and after |
| 121 |
* the `templately_module_requirements_{name}` filter runs. A single unmet requirement |
| 122 |
* drops the module entirely — no hooks, no REST endpoints, no assets — and records a |
| 123 |
* human-readable reason in {@see Modules_Manager::get_unavailable_modules()}. Like |
| 124 |
* `is_active()` and `get_dependencies()`, this MUST be safe to call on a |
| 125 |
* not-yet-constructed instance (no dependence on constructor state). |
| 126 |
* |
| 127 |
* Every key is optional; an empty array (the default) imposes no requirement: |
| 128 |
* - `php` (string) Minimum PHP version, compared with `version_compare( PHP_VERSION, … )`. |
| 129 |
* - `wp` (string) Minimum WordPress version, compared against `get_bloginfo( 'version' )`. |
| 130 |
* - `classes` (string[]) Every entry must satisfy `class_exists()` or `interface_exists()`. |
| 131 |
* - `functions` (string[]) Every entry must satisfy `function_exists()`. |
| 132 |
* - `plugins` (string[]) Active-plugin basenames (`'dir/file.php'`), checked against the |
| 133 |
* active-plugins option (plus network-active plugins on multisite). |
| 134 |
* - `multisite` (bool) Must equal `is_multisite()`. |
| 135 |
* |
| 136 |
* @return array |
| 137 |
*/ |
| 138 |
public function get_requirements(): array { |
| 139 |
return []; |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Experimental-flag metadata (FR-009). Metadata only — does not alter boot behaviour. |
| 144 |
* |
| 145 |
* @return array|false |
| 146 |
*/ |
| 147 |
public function get_experimental_data() { |
| 148 |
return false; |
| 149 |
} |
| 150 |
|
| 151 |
/** |
| 152 |
* Named sub-feature gates, each bound to a host capability key (spec 053). |
| 153 |
* |
| 154 |
* `[ 'gate-name' => 'capability-key' ]`. A gate NEVER drops the module — |
| 155 |
* that is `get_requirements()`/`is_active()`'s job. A module with unmet |
| 156 |
* gates boots normally; only the code paths it guards with |
| 157 |
* {@see has_gate()} stay inactive. Unmet gates are surfaced per module in |
| 158 |
* {@see Modules_Manager::get_unmet_gates()} for the developer console. |
| 159 |
* |
| 160 |
* @return array<string, string> |
| 161 |
*/ |
| 162 |
public function get_capability_gates(): array { |
| 163 |
return []; |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Whether one of this module's own declared gates is met (spec 053). |
| 168 |
* |
| 169 |
* Resolution is lazy — nothing is probed until asked — so this is safe at |
| 170 |
* any point during or after `init_hooks()`. A gate name the module never |
| 171 |
* declared answers false with a development-mode notice, never a fatal. |
| 172 |
* |
| 173 |
* @param string $gate Gate name declared in {@see get_capability_gates()}. |
| 174 |
* @return bool |
| 175 |
*/ |
| 176 |
protected function has_gate( $gate ): bool { |
| 177 |
$gates = $this->get_capability_gates(); |
| 178 |
|
| 179 |
if ( ! isset( $gates[ $gate ] ) ) { |
| 180 |
_doing_it_wrong( |
| 181 |
__METHOD__, |
| 182 |
esc_html( sprintf( 'Templately module "%s" queried undeclared gate "%s"; answering unmet.', $this->get_name(), $gate ) ), |
| 183 |
'3.8.0' |
| 184 |
); |
| 185 |
return false; |
| 186 |
} |
| 187 |
|
| 188 |
return Capabilities::get_instance()->has( $gates[ $gate ] ); |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* Register WordPress hooks. The only lifecycle method that runs during construction. |
| 193 |
* |
| 194 |
* @return void |
| 195 |
*/ |
| 196 |
protected function init_hooks(): void {} |
| 197 |
|
| 198 |
/** |
| 199 |
* Register the module's REST endpoints. |
| 200 |
* |
| 201 |
* MUST call `->register_routes()` explicitly on every endpoint (never rely on a |
| 202 |
* registry drain). Runs on `rest_api_init` via {@see boot_rest_routes()} — NEVER from |
| 203 |
* the constructor (constitution §VIII). |
| 204 |
* |
| 205 |
* @return void |
| 206 |
*/ |
| 207 |
public function register_rest_routes(): void {} |
| 208 |
|
| 209 |
/** |
| 210 |
* The public `rest_api_init` callback the base constructor hooks. Kept separate from |
| 211 |
* `register_rest_routes()` so the hook callback is stable and the intent is explicit. |
| 212 |
* |
| 213 |
* @return void |
| 214 |
*/ |
| 215 |
public function boot_rest_routes(): void { |
| 216 |
$this->register_rest_routes(); |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* Enqueue the module's PHP-side assets. Runs on `admin_enqueue_scripts`, not the |
| 221 |
* constructor. |
| 222 |
* |
| 223 |
* @return void |
| 224 |
*/ |
| 225 |
public function enqueue_assets(): void {} |
| 226 |
|
| 227 |
/** |
| 228 |
* Register a named internal sub-component (FR-007). |
| 229 |
* |
| 230 |
* `@internal` — access is convention-bounded, not enforced by PHP visibility. A |
| 231 |
* name collision preserves the first registrant and emits a diagnostic notice; it |
| 232 |
* never overwrites (edge case: sub-component name collision). |
| 233 |
* |
| 234 |
* @param string $id Unique sub-component id within this module. |
| 235 |
* @param mixed $instance The sub-component instance. |
| 236 |
* @return void |
| 237 |
*/ |
| 238 |
public function add_component( string $id, $instance ): void { |
| 239 |
if ( isset( $this->components[ $id ] ) ) { |
| 240 |
$this->diagnostic_notice( |
| 241 |
sprintf( |
| 242 |
'Templately module "%s": sub-component "%s" is already registered; keeping the first instance.', |
| 243 |
$this->get_name(), |
| 244 |
$id |
| 245 |
) |
| 246 |
); |
| 247 |
return; |
| 248 |
} |
| 249 |
|
| 250 |
$this->components[ $id ] = $instance; |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* Retrieve a named sub-component (FR-007). |
| 255 |
* |
| 256 |
* `@internal` — intended for use only from within the owning module. Returns `null` |
| 257 |
* for an unregistered id — never a wrong instance. |
| 258 |
* |
| 259 |
* @param string $id Sub-component id. |
| 260 |
* @return mixed|null |
| 261 |
*/ |
| 262 |
public function get_component( string $id ) { |
| 263 |
return $this->components[ $id ] ?? null; |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* Emit a non-fatal diagnostic notice (FR-011). |
| 268 |
* |
| 269 |
* @param string $message Human-readable diagnostic. |
| 270 |
* @return void |
| 271 |
*/ |
| 272 |
private function diagnostic_notice( string $message ): void { |
| 273 |
trigger_error( esc_html( $message ), E_USER_NOTICE ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error |
| 274 |
|
| 275 |
// Templately's dedicated log file, not debug.log (WP_DEBUG_LOG-gated inside). |
| 276 |
\Templately\Utils\Helper::log( $message, 'modules', 'warning' ); |
| 277 |
} |
| 278 |
} |
| 279 |
|