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 / Capabilities.php

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

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