| 1 |
<?php |
| 2 |
/** |
| 3 |
* Capabilities — the host-capability registry (spec 053). |
| 4 |
* |
| 5 |
* Answers "can this host do X?" once per request, consistently, for PHP and |
| 6 |
* (via the localized capability map) for every Templately JS bundle. Detector |
| 7 |
* first: a declared probe beats the version comparison, so backports and |
| 8 |
* features arriving early through the Gutenberg plugin light up without a |
| 9 |
* plugin release. The per-key filter `templately_capability_{$key}` has the |
| 10 |
* final word — site-wide kill switch and test hook in one. |
| 11 |
* |
| 12 |
* PHP 7.2 SYNTAX ONLY in this file (FR-038): the published readme floor is |
| 13 |
* the authoritative compatibility contract — closures, not arrow functions; |
| 14 |
* no typed properties. |
| 15 |
* |
| 16 |
* @package Templately |
| 17 |
*/ |
| 18 |
|
| 19 |
namespace Templately\Core; |
| 20 |
|
| 21 |
use Templately\Utils\Base; |
| 22 |
|
| 23 |
class Capabilities extends Base { |
| 24 |
/** |
| 25 |
* Registered declarations, keyed by capability key. |
| 26 |
* |
| 27 |
* @var array<string, array> |
| 28 |
*/ |
| 29 |
private $declarations = []; |
| 30 |
|
| 31 |
/** |
| 32 |
* Memoized answers for this request. Only cacheable answers land here — |
| 33 |
* premature (stage not reached) and unregistered answers never do, so a |
| 34 |
* later query can still resolve truthfully (FR-007 / FR-020). |
| 35 |
* |
| 36 |
* @var array<string, array> key => [ 'value' => bool, 'decided_by' => string ] |
| 37 |
*/ |
| 38 |
private $answers = []; |
| 39 |
|
| 40 |
/** |
| 41 |
* Register a capability. Metadata only — the probe is NOT executed here |
| 42 |
* (FR-006); resolution is lazy, at first query. |
| 43 |
* |
| 44 |
* Duplicate keys: the first registration wins, deterministically, with a |
| 45 |
* development-mode notice (FR-009). |
| 46 |
* |
| 47 |
* @param string $key Stable kebab-case capability key. |
| 48 |
* @param array $def { |
| 49 |
* @type callable|null $probe Optional presence probe; truthy = available. |
| 50 |
* @type string|null $wp Optional minimum WordPress version. |
| 51 |
* @type string|null $stage Optional hook name the probe must wait for. |
| 52 |
* @type string $posture 'degrade'|'polyfill'|'hard' (descriptive). |
| 53 |
* @type string $note Human-readable note for the dev console. |
| 54 |
* } |
| 55 |
* @return void |
| 56 |
*/ |
| 57 |
public function register( $key, array $def ) { |
| 58 |
if ( ! $this->is_usable_key( $key ) ) { |
| 59 |
_doing_it_wrong( |
| 60 |
__METHOD__, |
| 61 |
esc_html( sprintf( 'Templately capability key must be a string; %s given. Declaration ignored.', gettype( $key ) ) ), |
| 62 |
'3.8.0' |
| 63 |
); |
| 64 |
return; |
| 65 |
} |
| 66 |
|
| 67 |
if ( isset( $this->declarations[ $key ] ) ) { |
| 68 |
_doing_it_wrong( |
| 69 |
__METHOD__, |
| 70 |
esc_html( sprintf( 'Capability "%s" is already registered; keeping the first declaration.', $key ) ), |
| 71 |
'3.8.0' |
| 72 |
); |
| 73 |
return; |
| 74 |
} |
| 75 |
|
| 76 |
$this->declarations[ $key ] = $def + [ |
| 77 |
'probe' => null, |
| 78 |
'wp' => null, |
| 79 |
'stage' => null, |
| 80 |
'posture' => 'degrade', |
| 81 |
'note' => '', |
| 82 |
]; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Whether a capability is available on this host. |
| 87 |
* |
| 88 |
* Unknown key: false, with a development-mode notice, never fatal (FR-008). |
| 89 |
* |
| 90 |
* @param string $key Capability key. |
| 91 |
* @return bool |
| 92 |
*/ |
| 93 |
public function has( $key ) { |
| 94 |
$answer = $this->resolve( $key ); |
| 95 |
return $answer['value']; |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* The complete resolved map — the single source every JS surface receives |
| 100 |
* (FR-010, FR-025). |
| 101 |
* |
| 102 |
* @return array<string, bool> |
| 103 |
*/ |
| 104 |
public function get_map() { |
| 105 |
$map = []; |
| 106 |
foreach ( array_keys( $this->declarations ) as $key ) { |
| 107 |
$map[ $key ] = $this->has( $key ); |
| 108 |
} |
| 109 |
return $map; |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* Explain one capability: value, which input decided it, posture, note (FR-011). |
| 114 |
* |
| 115 |
* @param string $key Capability key. |
| 116 |
* @return array{key:string,value:bool,decided_by:string,posture:string,note:string} |
| 117 |
*/ |
| 118 |
public function explain( $key ) { |
| 119 |
$answer = $this->resolve( $key ); |
| 120 |
$declaration = $this->is_registered( $key ) ? $this->declarations[ $key ] : [ 'posture' => '', 'note' => '' ]; |
| 121 |
|
| 122 |
return [ |
| 123 |
'key' => $key, |
| 124 |
'value' => $answer['value'], |
| 125 |
'decided_by' => $answer['decided_by'], |
| 126 |
'posture' => $declaration['posture'], |
| 127 |
'note' => $declaration['note'], |
| 128 |
]; |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Explain every registered capability — the dev-console feed (FR-022). |
| 133 |
* |
| 134 |
* @return array[] |
| 135 |
*/ |
| 136 |
public function explain_all() { |
| 137 |
$rows = []; |
| 138 |
foreach ( array_keys( $this->declarations ) as $key ) { |
| 139 |
$rows[] = $this->explain( $key ); |
| 140 |
} |
| 141 |
return $rows; |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* Whether a key has been registered (used by the gate layer to distinguish |
| 146 |
* "unmet because capability missing on host" from "unmet because nobody |
| 147 |
* registered the key" — FR-020). |
| 148 |
* |
| 149 |
* @param string $key Capability key. |
| 150 |
* @return bool |
| 151 |
*/ |
| 152 |
public function is_registered( $key ) { |
| 153 |
if ( ! $this->is_usable_key( $key ) ) { |
| 154 |
return false; |
| 155 |
} |
| 156 |
return isset( $this->declarations[ $key ] ); |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* Whether a key can be used as an array offset at all. |
| 161 |
* |
| 162 |
* The registry is asked about keys that come from module declarations, and |
| 163 |
* `get_capability_gates(): array` constrains the container, not its values — |
| 164 |
* so an array or object can reach any of the offset lookups here and fatal |
| 165 |
* ("Cannot access offset of type array in isset or empty" on PHP 8). Every |
| 166 |
* offset read guards through this; only {@see resolve()} raises the notice, |
| 167 |
* so one bad query produces one warning. |
| 168 |
* |
| 169 |
* @param mixed $key Candidate capability key. |
| 170 |
* @return bool |
| 171 |
*/ |
| 172 |
private function is_usable_key( $key ) { |
| 173 |
return is_string( $key ) || is_int( $key ); |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* Resolve a capability: probe → version → override-final (FR-003/FR-004), |
| 178 |
* memoized per request when cacheable (FR-005/FR-007). |
| 179 |
* |
| 180 |
* @param string $key Capability key. |
| 181 |
* @return array{value:bool,decided_by:string} |
| 182 |
*/ |
| 183 |
private function resolve( $key ) { |
| 184 |
// A non-scalar key would fatal on the array offsets below ("Cannot access |
| 185 |
// offset of type array in isset or empty", PHP 8). The registry's contract |
| 186 |
// is never-fatal (FR-008), and a malformed key reaches here whenever a |
| 187 |
// module's get_capability_gates() returns a non-string VALUE — the array |
| 188 |
// return type constrains the container, not its elements. Answer |
| 189 |
// unavailable, uncached, with a development-mode notice. |
| 190 |
if ( ! $this->is_usable_key( $key ) ) { |
| 191 |
_doing_it_wrong( |
| 192 |
__METHOD__, |
| 193 |
esc_html( sprintf( 'Templately capability key must be a string; %s given. Answering unavailable.', gettype( $key ) ) ), |
| 194 |
'3.8.0' |
| 195 |
); |
| 196 |
return [ |
| 197 |
'value' => false, |
| 198 |
'decided_by' => 'invalid-key', |
| 199 |
]; |
| 200 |
} |
| 201 |
|
| 202 |
if ( isset( $this->answers[ $key ] ) ) { |
| 203 |
return $this->answers[ $key ]; |
| 204 |
} |
| 205 |
|
| 206 |
if ( ! isset( $this->declarations[ $key ] ) ) { |
| 207 |
_doing_it_wrong( |
| 208 |
__METHOD__, |
| 209 |
esc_html( sprintf( 'Unknown Templately capability "%s" queried; answering unavailable.', $key ) ), |
| 210 |
'3.8.0' |
| 211 |
); |
| 212 |
// Deliberately NOT cached: the key may be registered later (FR-020). |
| 213 |
return $this->finalize( $key, false, 'unregistered', false ); |
| 214 |
} |
| 215 |
|
| 216 |
$declaration = $this->declarations[ $key ]; |
| 217 |
|
| 218 |
// A probe that must wait for a lifecycle stage returns a NON-cached |
| 219 |
// unavailable before that stage — the truthful answer is still |
| 220 |
// produced by a later query (FR-007). |
| 221 |
// |
| 222 |
// Unconditionally unavailable, even when a `wp` minimum is also |
| 223 |
// declared: a capability carries a probe PRECISELY BECAUSE its version |
| 224 |
// minimum is not sufficient evidence, so falling back to the version |
| 225 |
// while the probe cannot run reintroduces the imprecision the probe |
| 226 |
// exists to remove. Observed with the one staged seed key — on WP 7.1, |
| 227 |
// `wp-knowledge-cpt` would answer available before `init` while |
| 228 |
// `post_type_exists('wp_knowledge')` is false, so a gate consulted at |
| 229 |
// `plugins_loaded` (module boot — exactly where gates are consulted) |
| 230 |
// would enable a path against a post type that does not exist. |
| 231 |
if ( null !== $declaration['probe'] ) { |
| 232 |
if ( null !== $declaration['stage'] && ! did_action( $declaration['stage'] ) ) { |
| 233 |
return $this->finalize( $key, false, 'premature', false ); |
| 234 |
} |
| 235 |
|
| 236 |
if ( ! is_callable( $declaration['probe'] ) ) { |
| 237 |
return $this->finalize( $key, false, 'probe-error', true ); |
| 238 |
} |
| 239 |
|
| 240 |
try { |
| 241 |
$result = call_user_func( $declaration['probe'] ); |
| 242 |
} catch ( \Throwable $e ) { |
| 243 |
// A broken probe is an unavailable capability, never a fatal (FR-012). |
| 244 |
return $this->finalize( $key, false, 'probe-error', true ); |
| 245 |
} |
| 246 |
|
| 247 |
return $this->finalize( $key, (bool) $result, 'probe', true ); |
| 248 |
} |
| 249 |
|
| 250 |
if ( null !== $declaration['wp'] ) { |
| 251 |
return $this->finalize( $key, $this->version_satisfied( $declaration['wp'] ), 'version', true ); |
| 252 |
} |
| 253 |
|
| 254 |
return $this->finalize( $key, false, 'undeclared', true ); |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Apply the per-key override filter (final word — FR-004), coerce, memoize. |
| 259 |
* |
| 260 |
* @param string $key Capability key. |
| 261 |
* @param bool $value Computed answer before the override. |
| 262 |
* @param string $decided_by What produced the computed answer. |
| 263 |
* @param bool $cacheable Whether the answer may be memoized. |
| 264 |
* @return array{value:bool,decided_by:string} |
| 265 |
*/ |
| 266 |
private function finalize( $key, $value, $decided_by, $cacheable ) { |
| 267 |
/** |
| 268 |
* The final word on one capability — kill switch and test hook. |
| 269 |
* |
| 270 |
* @param bool $value Computed answer. |
| 271 |
* @param array $declaration The registered declaration ([] when unregistered). |
| 272 |
*/ |
| 273 |
$filtered = apply_filters( |
| 274 |
"templately_capability_{$key}", |
| 275 |
$value, |
| 276 |
isset( $this->declarations[ $key ] ) ? $this->declarations[ $key ] : [] |
| 277 |
); |
| 278 |
|
| 279 |
// Anything ambiguous coerces toward unavailable (spec edge case). |
| 280 |
$final = is_bool( $filtered ) ? $filtered : (bool) $filtered; |
| 281 |
|
| 282 |
// Compare the COERCED answer, not the raw filter return: a filter that |
| 283 |
// hands back `1` for a value that was already `true` changes nothing and |
| 284 |
// must not be labelled an override (FR-011). |
| 285 |
if ( $final !== $value ) { |
| 286 |
$decided_by = 'override'; |
| 287 |
} |
| 288 |
|
| 289 |
$answer = [ |
| 290 |
'value' => $final, |
| 291 |
'decided_by' => $decided_by, |
| 292 |
]; |
| 293 |
|
| 294 |
if ( $cacheable ) { |
| 295 |
$this->answers[ $key ] = $answer; |
| 296 |
} |
| 297 |
|
| 298 |
return $answer; |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Compare a minimum WordPress version against the running host. Unreadable |
| 303 |
* host version resolves unavailable, never an error (FR-013). Mirrors |
| 304 |
* Modules_Manager::check_requirements(). |
| 305 |
* |
| 306 |
* PRE-RELEASE HOSTS SATISFY THE RELEASE THEY ARE A PRE-RELEASE OF (FR-048). |
| 307 |
* |
| 308 |
* `version_compare()` orders `7.1-beta4` and `7.1-RC1` BELOW plain `7.1`, |
| 309 |
* which is correct for "is this newer than that" and wrong for the only |
| 310 |
* question this registry asks: "does this host carry the API that landed in |
| 311 |
* 7.1?" A beta or RC of 7.1 carries 7.1's APIs — that is what a release |
| 312 |
* candidate IS — so a raw comparison makes every version-only capability |
| 313 |
* resolve unavailable for the entire pre-release cycle, and the code behind |
| 314 |
* it becomes untestable until the day of the final tag. The published |
| 315 |
* requirement is the opposite: gated code must be exercisable on an RC. |
| 316 |
* |
| 317 |
* So the HOST version is normalized to its release core before comparing — |
| 318 |
* everything from the first `-` onward is dropped: |
| 319 |
* |
| 320 |
* 7.1-beta4 => 7.1 satisfies 7.1, still fails 7.2 |
| 321 |
* 7.1-RC1 => 7.1 satisfies 7.1, still fails 7.2 |
| 322 |
* 7.2-alpha-12345-src => 7.2 satisfies 7.2 (WordPress trunk: trunk is |
| 323 |
* where 7.2's APIs land first, and the |
| 324 |
* alpha/-src suffix is a build marker, not |
| 325 |
* a statement about which APIs are present) |
| 326 |
* 7.0.5 => 7.0.5 satisfies 7.0 (unchanged; no suffix) |
| 327 |
* 6.9 => 6.9 still fails 7.1 (unchanged) |
| 328 |
* |
| 329 |
* The DECLARED MINIMUM is deliberately NOT normalized. A minimum is authored |
| 330 |
* by us and is always a plain release number; normalizing it too would let a |
| 331 |
* hypothetical `wp => '7.1-RC1'` silently widen to all of 7.1, and would make |
| 332 |
* the two sides of the comparison lie in different ways. Only the host's own |
| 333 |
* self-report — which we do not control — is coerced. (That asymmetry is a |
| 334 |
* rule about authorship rather than an observable behaviour: normalizing a |
| 335 |
* suffixed minimum M could only change the answer for a host whose release |
| 336 |
* core falls in [M, M-without-suffix), and a normalized host core carries no |
| 337 |
* suffix, so that interval is always empty. Keep the asymmetry anyway — it is |
| 338 |
* what makes the code say what it means.) |
| 339 |
* |
| 340 |
* Consequence to accept knowingly: an EARLY 7.1 alpha that predates the API |
| 341 |
* answers available. Version-only keys are a stopgap until the release's |
| 342 |
* Field Guide confirms a probe symbol (see Capability_Seed); a probe, once |
| 343 |
* declared, decides and this comparison stops mattering for that key. Being |
| 344 |
* optimistic for a few alpha weeks is the price of being testable on the RC, |
| 345 |
* and the `templately_capability_{$key}` filter forces either answer. |
| 346 |
* |
| 347 |
* @param string $minimum Minimum WordPress version. |
| 348 |
* @return bool |
| 349 |
*/ |
| 350 |
private function version_satisfied( $minimum ) { |
| 351 |
$wp_version = get_bloginfo( 'version' ); |
| 352 |
if ( '' === $wp_version && isset( $GLOBALS['wp_version'] ) ) { |
| 353 |
$wp_version = $GLOBALS['wp_version']; |
| 354 |
} |
| 355 |
|
| 356 |
if ( ! is_string( $wp_version ) || '' === $wp_version ) { |
| 357 |
return false; |
| 358 |
} |
| 359 |
|
| 360 |
return version_compare( $this->release_core( $wp_version ), (string) $minimum, '>=' ); |
| 361 |
} |
| 362 |
|
| 363 |
/** |
| 364 |
* Strip a pre-release / build suffix from a host version string. |
| 365 |
* |
| 366 |
* WordPress reports `X.Y`, `X.Y.Z`, `X.Y-beta1`, `X.Y-RC1`, `X.Y-alpha-NNNNN-src` |
| 367 |
* and `X.Y-src`. Every suffix shape starts at the first hyphen, so the release |
| 368 |
* core is simply everything before it. A version with no hyphen is returned |
| 369 |
* unchanged; a string that is nothing BUT a suffix (`-beta1`) would strip to |
| 370 |
* empty, so it is returned untouched and left to fail the comparison. |
| 371 |
* |
| 372 |
* PHP 7.2 syntax only (FR-038). |
| 373 |
* |
| 374 |
* @param string $version Raw host version. |
| 375 |
* @return string Release core. |
| 376 |
*/ |
| 377 |
private function release_core( $version ) { |
| 378 |
$version = (string) $version; |
| 379 |
$dash = strpos( $version, '-' ); |
| 380 |
|
| 381 |
if ( false === $dash || 0 === $dash ) { |
| 382 |
return $version; |
| 383 |
} |
| 384 |
|
| 385 |
return substr( $version, 0, $dash ); |
| 386 |
} |
| 387 |
|
| 388 |
/** |
| 389 |
* Test-only: drop all declarations and memoized answers. The unit suite |
| 390 |
* re-registers per test; production code never calls this. |
| 391 |
* |
| 392 |
* @internal |
| 393 |
* @return void |
| 394 |
*/ |
| 395 |
public function reset_for_tests() { |
| 396 |
$this->declarations = []; |
| 397 |
$this->answers = []; |
| 398 |
} |
| 399 |
} |
| 400 |
|