├── .editorconfig ├── src ├── Html │ ├── Element │ │ ├── H3WithClass.php │ │ ├── DivWithClass.php │ │ ├── SelectElement.php │ │ └── ListElement.php │ └── Element.php ├── Singleton.php ├── HookConstructorTo.php ├── HookInitTo.php ├── Db.php ├── HookAnnotation.php ├── Is.php ├── SiteInfo.php ├── helpers.php ├── Script.php ├── HookProxy.php └── HookProxyListed.php ├── LICENSE └── composer.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | eclint_block_comment_start = /* 13 | eclint_block_comment = * 14 | eclint_block_comment_end = */ 15 | -------------------------------------------------------------------------------- /src/Html/Element/H3WithClass.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress\Html\Element; 14 | 15 | use SzepeViktor\SentencePress\Html\Element; 16 | 17 | /** 18 | * Create an h3 element with classes. 19 | */ 20 | class H3WithClass extends Element 21 | { 22 | public function __construct(string $classString, string $content = '') { 23 | parent::__construct('h3', ['class' => $classString], $content); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Html/Element/DivWithClass.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress\Html\Element; 14 | 15 | use SzepeViktor\SentencePress\Html\Element; 16 | 17 | /** 18 | * Create a div element with classes. 19 | */ 20 | class DivWithClass extends Element 21 | { 22 | public function __construct(string $classString, string $content = '') { 23 | parent::__construct('div', ['class' => $classString], $content); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Singleton.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | /** 16 | * Singleton trait. 17 | */ 18 | trait Singleton 19 | { 20 | /** @var self|null */ 21 | private static $instance; 22 | 23 | public static function getInstance(): self 24 | { 25 | if (!self::$instance instanceof self) { 26 | self::$instance = new self; 27 | } 28 | 29 | return self::$instance; 30 | } 31 | 32 | private function __construct() {} 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Viktor Szépe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Html/Element/SelectElement.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress\Html\Element; 14 | 15 | use SzepeViktor\SentencePress\Html\Element; 16 | 17 | use function esc_html; 18 | 19 | /** 20 | * Create a select element with attributes from a list of option elements. 21 | */ 22 | class SelectElement extends Element 23 | { 24 | /** 25 | * @param array $attributes HTML attributes of the select element. 26 | * @param array $options Option elements value=>raw_item. 27 | */ 28 | public function __construct(array $attributes = [], array $options = [], string $currentValue = '') { 29 | parent::__construct('select', $attributes, \implode(\array_map( 30 | static function (string $optionValue, string $optionItem) use ($currentValue): string { 31 | return (new Element( 32 | 'option', 33 | \array_merge( 34 | ['value' => $optionValue], 35 | $optionValue === $currentValue ? ['selected' => null] : [] 36 | ), 37 | esc_html($optionItem) 38 | ))->render(); 39 | }, 40 | \array_keys($options), 41 | $options 42 | ))); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Html/Element/ListElement.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress\Html\Element; 14 | 15 | use SzepeViktor\SentencePress\Html\Element; 16 | 17 | /** 18 | * Create a list element with attributes from a list of children. 19 | */ 20 | class ListElement extends Element 21 | { 22 | /** 23 | * @param array $attributes HTML attributes of the parent. 24 | * @param list $childrenContent Raw HTML content of children. 25 | * @param string $childTagName Name of children tags. 26 | */ 27 | public function __construct( 28 | string $tagName = 'ul', 29 | array $attributes = [], 30 | array $childrenContent = [], 31 | string $childTagName = 'li' 32 | ) { 33 | parent::__construct($tagName, $attributes, \implode(array_map( 34 | static function (string $childContent) use ($childTagName): string { 35 | return \implode([ 36 | self::LESS_THAN_SIGN, 37 | $childTagName, 38 | self::GREATER_THAN_SIGN, 39 | $childContent, 40 | self::LESS_THAN_SIGN, 41 | self::SOLIDUS, 42 | $childTagName, 43 | self::GREATER_THAN_SIGN, 44 | ]); 45 | }, 46 | $childrenContent 47 | ))); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "szepeviktor/sentencepress", 3 | "description": "OOP toolkit for daily tasks in WordPress development.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "tools", 7 | "oop", 8 | "wordpress" 9 | ], 10 | "require": { 11 | "php": "^7.4 || ^8.0" 12 | }, 13 | "require-dev": { 14 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0", 15 | "johnbillion/wp-compat": "^1.1", 16 | "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", 17 | "szepeviktor/phpstan-wordpress": "^2.0", 18 | "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "SzepeViktor\\SentencePress\\": "src/" 23 | }, 24 | "files": [ 25 | "src/helpers.php" 26 | ] 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "SzepeViktor\\SentencePress\\Tests\\": "tests/" 31 | } 32 | }, 33 | "config": { 34 | "allow-plugins": { 35 | "dealerdirect/phpcodesniffer-composer-installer": true 36 | }, 37 | "optimize-autoloader": true 38 | }, 39 | "scripts": { 40 | "cs": [ 41 | "@phpcs:set-php-version", 42 | "@phpcs:run" 43 | ], 44 | "phpcs:run": "phpcs -s --standard=PSR12NeutronRuleset --exclude=PEAR.Functions.FunctionCallSignature src/", 45 | "phpcs:set-php-version": "phpcs --config-set php_version 70400", 46 | "phpstan": "phpstan analyze -v", 47 | "syntax": "git ls-files --cached -z -- '*.php' | xargs -0 -L 1 -P 4 -- php -l", 48 | "test": [ 49 | "@composer validate --strict", 50 | "@syntax", 51 | "@phpstan", 52 | "@cs" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/HookConstructorTo.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use ReflectionClass; 16 | 17 | use function add_action; 18 | 19 | /** 20 | * Hook class constructor on to a specific action. 21 | * 22 | * Only actions are supported. 23 | * Example call with priority zero. 24 | * 25 | * HookConstructorTo::{'acf/init'}(MyClass::class, 0); 26 | */ 27 | class HookConstructorTo 28 | { 29 | protected const DEFAULT_PRIORITY = 10; 30 | 31 | /** 32 | * Hook to the action in the method name. 33 | * 34 | * @param string $actionTag 35 | * @param array{0?: class-string, 1?: int} $arguments 36 | */ 37 | public static function __callStatic(string $actionTag, array $arguments): void 38 | { 39 | if (! isset($arguments[0])) { 40 | throw new \ArgumentCountError('Class name must be supplied.'); 41 | } 42 | 43 | /** @var class-string $class */ 44 | $class = $arguments[0]; 45 | 46 | $constructor = (new ReflectionClass($class))->getConstructor(); 47 | if ($constructor === null) { 48 | throw new \ErrorException('The class must have a constructor defined.'); 49 | } 50 | 51 | // Hook the constructor. 52 | add_action( 53 | $actionTag, 54 | static function () use ($class): void { 55 | // Pass hook parameters to constructor. 56 | $args = func_get_args(); 57 | // phpcs:ignore NeutronStandard.Functions.VariableFunctions.VariableFunction 58 | new $class(...$args); 59 | }, 60 | (int)($arguments[1] ?? self::DEFAULT_PRIORITY), 61 | $constructor->getNumberOfParameters() 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/HookInitTo.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use ReflectionClass; 16 | 17 | use function add_filter; 18 | 19 | /** 20 | * Hook init() method on to a specific action or filter. 21 | * 22 | * Example call with priority zero. 23 | * 24 | * HookInitTo::plugins_loaded(MyClass::class, 0); 25 | */ 26 | class HookInitTo 27 | { 28 | protected const DEFAULT_PRIORITY = 10; 29 | 30 | /** 31 | * Hook to the action in the method name. 32 | * 33 | * @param string $actionTag 34 | * @param array{0?: class-string, 1?: int} $arguments 35 | * 36 | * @throws \ArgumentCountError 37 | * @throws \ReflectionException 38 | */ 39 | public static function __callStatic(string $actionTag, array $arguments): void 40 | { 41 | if (! isset($arguments[0])) { 42 | throw new \ArgumentCountError('Class name must be supplied.'); 43 | } 44 | 45 | /** @var class-string $class */ 46 | $class = $arguments[0]; 47 | 48 | $initMethod = (new ReflectionClass($class))->getMethod('init'); 49 | 50 | // Hook 'init' method. 51 | add_filter( 52 | $actionTag, 53 | // phpcs:ignore NeutronStandard.Functions.TypeHint.NoReturnType 54 | static function () use ($class) { 55 | // phpcs:ignore NeutronStandard.Functions.VariableFunctions.VariableFunction 56 | $instance = new $class(); 57 | // Pass hook parameters to init() 58 | $args = func_get_args(); 59 | 60 | return $instance->init(...$args); 61 | }, 62 | (int)($arguments[1] ?? self::DEFAULT_PRIORITY), 63 | $initMethod->getNumberOfParameters() 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Db.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | /** 16 | * Connect to global $wpdb instance from OOP code. 17 | * 18 | * Usage example. 19 | * 20 | * $db = new \Toolkit4WP\Db(); $db->prepare('...'); 21 | * 22 | * @see https://www.php.net/manual/en/language.oop5.magic.php 23 | */ 24 | class Db 25 | { 26 | /** 27 | * Get a property. 28 | * 29 | * @see https://codex.wordpress.org/Class_Reference/wpdb#Class_Variables 30 | * @param string $name 31 | * @return mixed 32 | */ 33 | public function __get(string $name) 34 | { 35 | global $wpdb; 36 | 37 | return $wpdb->$name; 38 | } 39 | 40 | /** 41 | * Noop on set. 42 | * 43 | * @param string $name 44 | * @param mixed $propertyValue 45 | * @return void 46 | * 47 | * phpcs:disable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter 48 | */ 49 | public function __set(string $name, $propertyValue): void 50 | { 51 | } 52 | 53 | // phpcs:enable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter 54 | 55 | /** 56 | * Execute a method. 57 | * 58 | * @see https://www.php.net/manual/en/language.oop5.overloading.php#object.call 59 | * @param string $name 60 | * @param list $arguments 61 | * @return mixed 62 | */ 63 | public function __call(string $name, array $arguments) 64 | { 65 | global $wpdb; 66 | 67 | $callback = [$wpdb, $name]; 68 | if (! \is_callable($callback)) { 69 | throw new \InvalidArgumentException(sprintf('Unknown wpdb method: %s', $name)); 70 | } 71 | 72 | // phpcs:ignore NeutronStandard.Functions.DisallowCallUserFunc.CallUserFunc 73 | return \call_user_func_array($callback, $arguments); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/HookAnnotation.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use ReflectionClass; 16 | use ReflectionMethod; 17 | 18 | use function add_filter; 19 | 20 | /** 21 | * Implement hooking in method annotation. 22 | * 23 | * Format: @hook tag 10 24 | * @hook tag first 25 | * @hook tag last 26 | * @hook skip 27 | * 28 | * mindplay/annotations may be a better solution. 29 | * 30 | * @see https://github.com/szepeviktor/wordpress-website-lifecycle/blob/master/WordPress-hooks.md 31 | */ 32 | trait HookAnnotation 33 | { 34 | protected function hookMethods(int $defaultPriority = 10): void 35 | { 36 | $classReflection = new ReflectionClass(self::class); 37 | // Look for hook tag in all public methods. 38 | foreach ($classReflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { 39 | // Do not hook constructor, use HookConstructorTo. 40 | if ($method->isConstructor()) { 41 | continue; 42 | } 43 | $hookDetails = $this->getMetadata((string)$method->getDocComment(), $defaultPriority); 44 | if ($hookDetails === null) { 45 | continue; 46 | } 47 | 48 | add_filter( 49 | $hookDetails['tag'], 50 | [$this, $method->name], 51 | $hookDetails['priority'], 52 | $method->getNumberOfParameters() 53 | ); 54 | } 55 | } 56 | 57 | /** 58 | * Read hook tag from docblock. 59 | * 60 | * @return array{tag: string, priority: int}|null 61 | */ 62 | protected function getMetadata(string $docComment, int $defaultPriority): ?array 63 | { 64 | $matches = []; 65 | if ( 66 | \preg_match( 67 | // @hook ( tag ) ( priority ) 68 | '/^\s+\*\s+@hook\s+([\w\/._=-]+)(?:\s+(\d+|first|last))?\s*$/m', 69 | $docComment, 70 | $matches 71 | ) !== 1 72 | ) { 73 | return null; 74 | } 75 | 76 | if ($matches[1] === 'skip') { 77 | return null; 78 | } 79 | 80 | if (! isset($matches[2])) { 81 | return [ 82 | 'tag' => $matches[1], 83 | 'priority' => $defaultPriority, 84 | ]; 85 | } 86 | 87 | switch ($matches[2]) { 88 | case 'first': 89 | $priority = PHP_INT_MIN; 90 | break; 91 | case 'last': 92 | $priority = PHP_INT_MAX; 93 | break; 94 | default: 95 | $priority = (int)$matches[2]; 96 | break; 97 | } 98 | 99 | return [ 100 | 'tag' => $matches[1], 101 | 'priority' => $priority, 102 | ]; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Is.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use WP_User; 16 | 17 | /** 18 | * Various request helpers. 19 | * 20 | * @see https://github.com/chesio/bc-security/blob/develop/classes/BlueChip/Security/Helpers/Is.php 21 | * X https://github.com/inpsyde/wp-context/blob/main/src/WpContext.php X 22 | */ 23 | class Is 24 | { 25 | /** 26 | * Whether we are in a live environment. 27 | * 28 | * @return bool 29 | */ 30 | public static function live(): bool 31 | { 32 | // Consider both production and staging environment as live. 33 | return \defined('WP_ENV') && \in_array(\WP_ENV, ['production', 'staging'], true); 34 | } 35 | 36 | /** 37 | * Whether given user is an administrator. 38 | * 39 | * @param \WP_User $user The given user. 40 | * @return bool 41 | */ 42 | public static function admin(WP_User $user): bool 43 | { 44 | return \is_multisite() ? \user_can($user, 'manage_network') : \user_can($user, 'manage_options'); 45 | } 46 | 47 | /** 48 | * Whether the current user is not logged in. 49 | * 50 | * @return bool 51 | */ 52 | public static function anonymousUsers(): bool 53 | { 54 | return ! \is_user_logged_in(); 55 | } 56 | 57 | /** 58 | * Whether the current user is a comment author. 59 | * 60 | * @return bool 61 | */ 62 | public static function commentAuthor(): bool 63 | { 64 | // phpcs:ignore WordPress.VIP.RestrictedVariables.cache_constraints___COOKIE 65 | return isset($_COOKIE[sprintf('comment_author_%s', \COOKIEHASH)]); 66 | } 67 | 68 | /** 69 | * Whether current webserver interface is CLI. 70 | * 71 | * @return bool 72 | */ 73 | public static function cli(): bool 74 | { 75 | return \php_sapi_name() === 'cli'; 76 | } 77 | 78 | /** 79 | * Whether current request is of the given type. 80 | * 81 | * All of them are available even before 'muplugins_loaded' action, 82 | * exceptions are commented. 83 | * 84 | * @param string $type Type of request. 85 | * @return bool 86 | * phpcs:disable SlevomatCodingStandard.Functions.FunctionLength.FunctionLength 87 | */ 88 | public static function request(string $type): bool 89 | { 90 | // phpcs:disable Squiz.PHP.CommentedOutCode.Found 91 | switch ($type) { 92 | case 'installing': 93 | return \wp_installing(); 94 | case 'index': 95 | return \wp_using_themes(); 96 | case 'frontend': 97 | // Use !request('frontend') for admin pages. 98 | return (! \is_admin() || \wp_doing_ajax() ) && ! \wp_doing_cron(); 99 | case 'admin': 100 | // Includes admin-ajax :( 101 | return \is_admin(); 102 | case 'login': 103 | return isset($_SERVER['REQUEST_URI']) 104 | && \is_string($_SERVER['REQUEST_URI']) 105 | && \explode('?', $_SERVER['REQUEST_URI'])[0] 106 | === \wp_parse_url(\wp_login_url('', true), \PHP_URL_PATH); 107 | case 'async-upload': 108 | return isset($_SERVER['SCRIPT_FILENAME']) 109 | && sprintf('%swp-admin/async-upload.php', \ABSPATH) === $_SERVER['SCRIPT_FILENAME']; 110 | case 'preview': // in 'parse_query' action if (is_main_query()) 111 | return \is_preview() || \is_customize_preview(); 112 | case 'autosave': // After 'heartbeat_received', 500 action 113 | // Autosave post while editing and Heartbeat. 114 | return \defined('DOING_AUTOSAVE') && \DOING_AUTOSAVE === true; 115 | case 'rest': // After 'parse_request' action 116 | return \defined('REST_REQUEST') && \REST_REQUEST === true; 117 | case 'ajax': 118 | return \wp_doing_ajax(); 119 | case 'xmlrpc': 120 | return \defined('XMLRPC_REQUEST') && \XMLRPC_REQUEST === true; 121 | case 'trackback': // In 'parse_query' 122 | return \is_trackback(); 123 | case 'search': // In 'parse_query' 124 | return \is_search(); 125 | case 'feed': // In 'parse_query' 126 | return \is_feed(); 127 | case 'robots': // In 'parse_query' 128 | return \is_robots(); 129 | case 'cron': 130 | return \wp_doing_cron(); 131 | case 'wp-cli': 132 | return \defined('WP_CLI') && \WP_CLI === true; 133 | default: 134 | \_doing_it_wrong(__METHOD__, \esc_html(\sprintf('Unknown request type: %s', $type)), '0.1.0'); 135 | return false; 136 | } 137 | // phpcs:enable 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/SiteInfo.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use WP_Filesystem_Base; 16 | 17 | use function trailingslashit; 18 | 19 | /** 20 | * Provide information on core paths and URLs. 21 | */ 22 | class SiteInfo 23 | { 24 | /** @var array */ 25 | protected $siteInfo = []; 26 | 27 | /** 28 | * Set paths and URLs. 29 | * 30 | * @see https://codex.wordpress.org/Determining_Plugin_and_Content_Directories 31 | */ 32 | protected function init(): void 33 | { 34 | $uploadPathAndUrl = \wp_upload_dir(); 35 | // phpcs:disable NeutronStandard.AssignAlign.DisallowAssignAlign.Aligned 36 | $this->siteInfo = [ 37 | // Core 38 | 'site_path' => \ABSPATH, 39 | 'site_url' => \site_url(), 40 | 'home_path' => $this->getHomePath(), 41 | 'home_url' => \get_home_url(), 42 | 'includes_path' => sprintf('%s%s', \ABSPATH, \WPINC), 43 | 'includes_url' => \includes_url(), 44 | 45 | // Content 46 | 'content_path' => \WP_CONTENT_DIR, 47 | 'content_url' => \content_url(), 48 | 'uploads_path' => $uploadPathAndUrl['basedir'], 49 | 'uploads_url' => $uploadPathAndUrl['baseurl'], 50 | 51 | // Plugins 52 | 'plugins_path' => \WP_PLUGIN_DIR, 53 | 'plugins_url' => \plugins_url(), 54 | 'mu_plugins_path' => \WPMU_PLUGIN_DIR, 55 | 'mu_plugins_url' => \plugins_url(), 56 | 57 | // Themes 58 | 'themes_root_path' => \get_theme_root(), 59 | 'themes_root_url' => \get_theme_root_uri(), 60 | 'parent_theme_path' => \get_template_directory(), 61 | 'parent_theme_url' => \get_template_directory_uri(), 62 | 'child_theme_path' => \get_stylesheet_directory(), 63 | 'child_theme_url' => \get_stylesheet_directory_uri(), 64 | ]; 65 | // phpcs:enable 66 | } 67 | 68 | /** 69 | * Public API. 70 | */ 71 | public function getPath(string $name): string 72 | { 73 | return $this->getInfo($name, '_path'); 74 | } 75 | 76 | /** 77 | * Public API. 78 | */ 79 | public function getUrl(string $name): string 80 | { 81 | return $this->getInfo($name, '_url'); 82 | } 83 | 84 | /** 85 | * Public API. 86 | */ 87 | public function getUrlBasename(string $name): string 88 | { 89 | return \basename($this->getUrl($name)); 90 | } 91 | 92 | /** 93 | * Public API. 94 | */ 95 | public function usingChildTheme(): bool 96 | { 97 | $this->setInfo(); 98 | 99 | return trailingslashit($this->siteInfo['parent_theme_path']) 100 | !== trailingslashit($this->siteInfo['child_theme_path']); 101 | } 102 | 103 | /** 104 | * Public API. 105 | */ 106 | public function isUploadsWritable(): bool 107 | { 108 | // phpcs:disable Squiz.NamingConventions.ValidVariableName 109 | global $wp_filesystem; 110 | if (! $wp_filesystem instanceof WP_Filesystem_Base) { 111 | require_once sprintf('%swp-admin/includes/file.php', \ABSPATH); 112 | } 113 | assert($wp_filesystem instanceof WP_Filesystem_Base); 114 | 115 | $this->setInfo(); 116 | 117 | $uploadsDir = trailingslashit($this->siteInfo['uploads_path']); 118 | 119 | return $wp_filesystem->exists($uploadsDir) && $wp_filesystem->is_writable($uploadsDir); 120 | // phpcs:enable 121 | } 122 | 123 | protected function setInfo(): void 124 | { 125 | if ($this->siteInfo !== []) { 126 | return; 127 | } 128 | 129 | if (! \did_action('init')) { 130 | throw new \LogicException('SiteInfo must be used in "init" action or later.'); 131 | } 132 | 133 | $this->init(); 134 | } 135 | 136 | protected function getInfo(string $name, string $suffix): string 137 | { 138 | $this->setInfo(); 139 | 140 | $infoKey = sprintf('%s%s', $name, $suffix); 141 | if (! \array_key_exists($infoKey, $this->siteInfo)) { 142 | throw new \DomainException(sprintf('Unknown SiteInfo key: %s', $infoKey)); 143 | } 144 | 145 | return trailingslashit($this->siteInfo[$infoKey]); 146 | } 147 | 148 | protected function getHomePath(): string 149 | { 150 | $homeUrl = \set_url_scheme(\get_option('home'), 'http'); 151 | $siteUrl = \set_url_scheme(\get_option('siteurl'), 'http'); 152 | if ($homeUrl !== '' && \strcasecmp($homeUrl, $siteUrl) !== 0) { 153 | $pos = \strripos(\ABSPATH, trailingslashit(\str_ireplace($homeUrl, '', $siteUrl))); 154 | if ($pos !== false) { 155 | return \substr(\ABSPATH, 0, $pos); 156 | } 157 | } 158 | 159 | return \ABSPATH; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use DOMDocument; 16 | use DOMElement; 17 | 18 | use function esc_url; 19 | use function get_template_directory_uri; 20 | use function wp_json_encode; 21 | 22 | /** 23 | * Return whether an array or a string is empty. 24 | * 25 | * Throw exception on int|float|bool|null|object|callable|resource 26 | * 27 | * @param mixed $thing 28 | */ 29 | function isEmpty($thing): bool 30 | { 31 | if (\is_array($thing)) { 32 | return $thing === []; 33 | } 34 | 35 | if (\is_string($thing)) { 36 | return $thing === ''; 37 | } 38 | 39 | throw new \InvalidArgumentException('Not a string nor an array.'); 40 | } 41 | 42 | /** 43 | * Check whether a value is a non-empty array. 44 | * 45 | * @param mixed $thing Array to be tested. 46 | */ 47 | function isNonEmptyArray($thing): bool 48 | { 49 | return \is_array($thing) && $thing !== []; 50 | } 51 | 52 | /** 53 | * @see https://html.spec.whatwg.org/multipage/introduction.html#syntax-errors 54 | */ 55 | function htmlComment(string $comment): string 56 | { 57 | // Replace two dashes with an &mdash to be on the safe side. 58 | return \sprintf('', \str_replace('--', '—', $comment)); 59 | } 60 | 61 | /** 62 | * @param mixed $condition 63 | */ 64 | function ifPrint($condition, string $content): void 65 | { 66 | if (! $condition) { 67 | return; 68 | } 69 | 70 | // phpcs:ignore Generic.PHP.ForbiddenFunctions.Found 71 | print $content; 72 | } 73 | 74 | function printAssetUri(string $path = ''): void 75 | { 76 | // phpcs:ignore Generic.PHP.ForbiddenFunctions.Found 77 | print esc_url( 78 | \sprintf( 79 | '%s/assets%s', 80 | \dirname(get_template_directory_uri()), 81 | $path 82 | ) 83 | ); 84 | } 85 | 86 | /** 87 | * Prepare an SVG for inline display. 88 | * 89 | * @link https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/SVG_In_HTML_Introduction 90 | * 91 | * @param string|false $path 92 | * @param array $attrs 93 | * 94 | * phpcs:disable Squiz.NamingConventions.ValidVariableName.NotCamelCaps,Squiz.NamingConventions.ValidFunctionName.NotCamelCaps 95 | */ 96 | function get_inline_svg($path, array $attrs = [], int $ttl = 3600): string 97 | { 98 | $empty_svg = ''; 99 | 100 | // phpcs:ignore PSR12NeutronRuleset.Strings.ConcatenationUsage.NotAllowed 101 | $cache_key = 'file-' . md5($path . wp_json_encode($attrs)); 102 | $xml = wp_cache_get($cache_key, 'svg-contents'); 103 | if ($xml !== false) { 104 | assert(is_string($xml)); 105 | 106 | return $xml; 107 | } 108 | 109 | if ($path === false || ! file_exists($path)) { 110 | return $empty_svg; 111 | } 112 | 113 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents 114 | $contents = file_get_contents($path); 115 | if ($contents === false || $contents === '') { 116 | return $empty_svg; 117 | } 118 | 119 | $document = new DOMDocument(); 120 | $document->loadXML($contents); 121 | 122 | $svg_elems = $document->getElementsByTagName('svg'); 123 | if ($svg_elems->length === 0) { 124 | return $empty_svg; 125 | } 126 | 127 | $svg_elem = $svg_elems->item(0); 128 | assert($svg_elem instanceof DOMElement, 'length === 0 makes sure we have element 0'); 129 | 130 | // May cause duplicate ID error 131 | $svg_elem->removeAttribute('id'); 132 | 133 | foreach ($attrs as $attr_name => $attr_value) { 134 | $svg_elem->setAttribute($attr_name, $attr_value); 135 | } 136 | 137 | // phpcs:disable Squiz.PHP.CommentedOutCode.Found 138 | // SVG version 1.1 139 | //$document->xmlVersion = '1.1'; 140 | //$svg_elem->setAttribute('version', '1.1'); 141 | 142 | // Handle the SVG as an image 143 | $svg_elem->setAttribute('role', 'img'); 144 | 145 | $xml = $document->saveXML($svg_elem); 146 | assert(is_string($xml), 'saveXML returns false on failure'); 147 | 148 | wp_cache_set($cache_key, $xml, 'svg-contents', $ttl); 149 | 150 | return $xml; 151 | } 152 | 153 | /** 154 | * Prepare an SVG from assets directory for inline display. 155 | * 156 | * @param array $attrs 157 | */ 158 | function get_inline_svg_asset(string $filename, array $attrs = [], int $ttl = 3600): string 159 | { 160 | return get_inline_svg(sprintf('%s/assets/img/%s', get_template_directory(), $filename), $attrs, $ttl); 161 | } 162 | 163 | /** 164 | * Prepare an SVG from Media for inline display. 165 | * 166 | * @link https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/SVG_In_HTML_Introduction 167 | * 168 | * @param array $attrs 169 | */ 170 | function get_inline_svg_media(int $attachment_id, array $attrs = [], int $ttl = 3600): string 171 | { 172 | return get_inline_svg(get_attached_file($attachment_id), $attrs, $ttl); 173 | } 174 | 175 | /** 176 | * Prepare an SVG as an icon. 177 | */ 178 | function get_inline_svg_icon(string $filename, string $class_string = 'icon'): string 179 | { 180 | return get_inline_svg_asset( 181 | sprintf('icon/%s', $filename), 182 | [ 183 | 'class' => $class_string, 184 | 'width' => '24', 185 | 'height' => '24', 186 | ] 187 | ); 188 | } 189 | -------------------------------------------------------------------------------- /src/Html/Element.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress\Html; 14 | 15 | use Traversable; 16 | 17 | use function esc_attr; 18 | use function esc_url; 19 | use function sanitize_key; 20 | 21 | /** 22 | * Create an HTML element with attributes. 23 | */ 24 | class Element 25 | { 26 | public const SPACE = ' '; 27 | 28 | public const LESS_THAN_SIGN = '<'; 29 | 30 | public const GREATER_THAN_SIGN = '>'; 31 | 32 | public const SOLIDUS = '/'; 33 | 34 | public const EQUALS_SIGN = '='; 35 | 36 | public const QUOTATION_MARK = '"'; 37 | 38 | /** @see https://html.spec.whatwg.org/multipage/syntax.html#void-elements */ 39 | public const VOIDS = [ 40 | 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 41 | 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', 42 | ]; 43 | 44 | protected string $tagName; 45 | 46 | /** @var array */ 47 | protected array $attributes; 48 | 49 | protected string $content; 50 | 51 | protected bool $isVoid; 52 | 53 | /** 54 | * Create an HTML element with pure PHP. 55 | * 56 | * @param array $attributes 57 | * @param string|\Traversable $content Raw HTML content. 58 | * @throws \Exception 59 | */ 60 | public function __construct(string $tagName = 'div', array $attributes = [], $content = '') 61 | { 62 | $this->tagName = sanitize_key($tagName); 63 | $this->attributes = $attributes; 64 | $this->content = $content instanceof Traversable 65 | ? \implode(\iterator_to_array($content)) 66 | : $content; 67 | $this->isVoid = \in_array($this->tagName, self::VOIDS, true); 68 | 69 | if ($this->isVoid && $this->content !== '') { 70 | throw new \Exception('Void HTML element with content.'); 71 | } 72 | } 73 | 74 | /** 75 | * @param array{tag: string, attrs: array} $skeleton 76 | * @throws \Exception 77 | */ 78 | public static function fromSkeleton(array $skeleton, string $content = ''): self 79 | { 80 | // @phpstan-ignore isset.offset,isset.offset 81 | if (! isset($skeleton['tag'], $skeleton['attrs'])) { 82 | throw new \Exception('Skeleton array needs tag and attrs elements.'); 83 | } 84 | 85 | return new self($skeleton['tag'], $skeleton['attrs'], $content); 86 | } 87 | 88 | public function setContent(string $content): void 89 | { 90 | $this->content = $content; 91 | } 92 | 93 | public function getContent(): string 94 | { 95 | return $this->content; 96 | } 97 | 98 | /** 99 | * @param array $attributes 100 | */ 101 | public function setAttributes(array $attributes): void 102 | { 103 | $this->attributes = $attributes; 104 | } 105 | 106 | /** 107 | * @return array 108 | */ 109 | public function getAttributes(): array 110 | { 111 | return $this->attributes; 112 | } 113 | 114 | public function setAttribute(string $attributeName, string $attributeValue): void 115 | { 116 | $this->attributes[$attributeName] = $attributeValue; 117 | } 118 | 119 | public function setBooleanWhen(string $attribute, bool $condition): void 120 | { 121 | if (! $condition) { 122 | return; 123 | } 124 | 125 | $this->attributes[$attribute] = null; 126 | } 127 | 128 | public function render(): string 129 | { 130 | $attributeString = $this->getAttributeString(); 131 | 132 | if ($attributeString !== '') { 133 | $attributeString = sprintf('%s%s', self::SPACE, $attributeString); 134 | } 135 | 136 | // Element. 137 | if ($this->isVoid) { 138 | return \implode([ 139 | self::LESS_THAN_SIGN, 140 | $this->tagName, 141 | $attributeString, 142 | self::GREATER_THAN_SIGN, 143 | ]); 144 | } 145 | 146 | return \implode([ 147 | self::LESS_THAN_SIGN, 148 | $this->tagName, 149 | $attributeString, 150 | self::GREATER_THAN_SIGN, 151 | $this->content, 152 | self::LESS_THAN_SIGN, 153 | self::SOLIDUS, 154 | $this->tagName, 155 | self::GREATER_THAN_SIGN, 156 | ]); 157 | } 158 | 159 | /** 160 | * Create an HTML attribute string from an array. 161 | */ 162 | protected function getAttributeString(): string 163 | { 164 | $attributeStrings = []; 165 | foreach ($this->attributes as $attributeName => $attributeValue) { 166 | $attributeName = \preg_replace('/[^a-z0-9-]/', '', \strtolower($attributeName)); 167 | 168 | // Boolean attributes. 169 | if ($attributeValue === null) { 170 | $attributeStrings[] = $attributeName; 171 | continue; 172 | } 173 | 174 | $attributeStrings[] = \implode([ 175 | $attributeName, 176 | self::EQUALS_SIGN, 177 | self::QUOTATION_MARK, 178 | \in_array($attributeName, ['href', 'src'], true) 179 | ? esc_url($attributeValue) 180 | : esc_attr($attributeValue), 181 | self::QUOTATION_MARK, 182 | ]); 183 | } 184 | 185 | return \implode(self::SPACE, $attributeStrings); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/Script.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use function add_filter; 16 | use function remove_filter; 17 | use function sanitize_title; 18 | use function wp_dequeue_script; 19 | use function wp_deregister_script; 20 | use function wp_enqueue_script; 21 | use function wp_parse_url; 22 | use function wp_register_script; 23 | 24 | /** 25 | * Handle a JavaScript resource. 26 | */ 27 | class Script 28 | { 29 | /** @var string */ 30 | protected $handle; 31 | 32 | /** @var string|false */ 33 | protected $src; 34 | 35 | /** @var list */ 36 | protected $deps; 37 | 38 | /** @var string|false|null */ 39 | protected $ver; 40 | 41 | /** @var bool */ 42 | protected $inFooter; 43 | 44 | /** @var bool */ 45 | protected $registered; 46 | 47 | /** @var array */ 48 | protected $attributes; 49 | 50 | /** 51 | * @param string $url Full URL of the script. 52 | */ 53 | public function __construct(string $url) 54 | { 55 | $this->handle = sanitize_title(pathinfo((string)wp_parse_url($url, PHP_URL_PATH), PATHINFO_FILENAME)); 56 | $this->src = $url; 57 | $this->deps = []; 58 | $this->ver = null; 59 | $this->inFooter = false; 60 | $this->registered = false; 61 | $this->attributes = []; 62 | } 63 | 64 | public static function aliasOf(string $handle): self 65 | { 66 | $script = new self($handle); 67 | $script->src = false; 68 | 69 | return $script; 70 | } 71 | 72 | public function setHandle(string $handle): self 73 | { 74 | $this->handle = $handle; 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * @param list $deps 81 | */ 82 | public function setDeps(array $deps): self 83 | { 84 | $this->deps = $deps; 85 | 86 | return $this; 87 | } 88 | 89 | public function setVer(string $ver): self 90 | { 91 | $this->ver = $ver; 92 | 93 | return $this; 94 | } 95 | 96 | public function setCoreVersion(): self 97 | { 98 | $this->ver = false; 99 | 100 | return $this; 101 | } 102 | 103 | public function removeVer(): self 104 | { 105 | $this->ver = null; 106 | 107 | return $this; 108 | } 109 | 110 | public function moveToFooter(): self 111 | { 112 | $this->inFooter = true; 113 | 114 | return $this; 115 | } 116 | 117 | public function register(): void 118 | { 119 | if ($this->registered) { 120 | return; 121 | } 122 | 123 | wp_register_script($this->handle, $this->src, $this->deps, $this->ver, $this->inFooter); 124 | $this->registered = true; 125 | } 126 | 127 | public function deregister(): void 128 | { 129 | if (! $this->registered) { 130 | return; 131 | } 132 | 133 | wp_deregister_script($this->handle); 134 | $this->registered = false; 135 | } 136 | 137 | public function enqueue(): void 138 | { 139 | if (! $this->registered) { 140 | $this->register(); 141 | } 142 | if ($this->attributes !== []) { 143 | add_filter('script_loader_tag', [$this, 'modifyScriptElement'], 10, 2); 144 | } 145 | // phpcs:ignore Squiz.PHP.CommentedOutCode.Found 146 | // @TODO if (! did_action('wp_enqueue_scripts')) doing_filter??? -> Exception 147 | wp_enqueue_script($this->handle); 148 | } 149 | 150 | public function dequeue(): void 151 | { 152 | if (! $this->registered) { 153 | return; 154 | } 155 | 156 | if ($this->attributes !== []) { 157 | remove_filter('script_loader_tag', [$this, 'modifyScriptElement'], 10); 158 | } 159 | wp_dequeue_script($this->handle); 160 | } 161 | 162 | public function modifyScriptElement(string $html, string $currentHandle): string 163 | { 164 | if ($currentHandle !== $this->handle) { 165 | return $html; 166 | } 167 | 168 | $attributes = array_reduce( 169 | $this->attributes, 170 | static function (array $attributes, string $attribute) use ($html): array { 171 | // Skip already present attributes 172 | if (preg_match(sprintf('#\s%s[\s>]#', preg_quote($attribute, '#')), $html)) { 173 | return $attributes; 174 | } 175 | 176 | $attributes[] = $attribute; 177 | 178 | return $attributes; 179 | }, 180 | [] 181 | ); 182 | 183 | if ($attributes === []) { 184 | return $html; 185 | } 186 | 187 | return str_replace(' src=', sprintf(' %s src=', implode(' ', $attributes)), $html); 188 | } 189 | 190 | /** 191 | * @param non-empty-string $attributeString 192 | */ 193 | public function addAttribute(string $attributeString): self 194 | { 195 | $this->attributes[] = $attributeString; 196 | 197 | return $this; 198 | } 199 | 200 | public function loadAsync(): self 201 | { 202 | return $this->addAttribute('async'); 203 | } 204 | 205 | public function executeDefer(): self 206 | { 207 | return $this->addAttribute('defer'); 208 | } 209 | 210 | public function executeAsModule(): self 211 | { 212 | return $this->addAttribute('type="module"'); 213 | } 214 | 215 | public function executeNomodule(): self 216 | { 217 | return $this->addAttribute('nomodule'); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/HookProxy.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use Closure; 16 | use ReflectionClass; 17 | use ReflectionMethod; 18 | 19 | use function _wp_filter_build_unique_id; 20 | use function add_filter; 21 | use function remove_filter; 22 | 23 | /** 24 | * Implement lazy hooking. 25 | */ 26 | trait HookProxy 27 | { 28 | use HookAnnotation; 29 | 30 | /** @var array */ 31 | protected $callablesAdded; 32 | 33 | protected function lazyHookFunction( 34 | string $actionTag, 35 | callable $callback, 36 | int $priority, 37 | int $argumentCount, 38 | string $filePath 39 | ): void { 40 | add_filter( 41 | $actionTag, 42 | $this->generateClosureWithFileLoad($actionTag, $callback, $filePath), 43 | $priority, 44 | $argumentCount 45 | ); 46 | } 47 | 48 | protected function lazyHookStaticMethod( 49 | string $actionTag, 50 | callable $callback, 51 | int $priority, 52 | int $argumentCount 53 | ): void { 54 | add_filter( 55 | $actionTag, 56 | $this->generateClosure($actionTag, $callback), 57 | $priority, 58 | $argumentCount 59 | ); 60 | } 61 | 62 | protected function lazyHookMethod( 63 | string $actionTag, 64 | callable $callback, 65 | int $priority, 66 | int $argumentCount, 67 | ?callable $injector = null 68 | ): void { 69 | add_filter( 70 | $actionTag, 71 | $this->generateClosureWithInjector($actionTag, $callback, $injector), 72 | $priority, 73 | $argumentCount 74 | ); 75 | } 76 | 77 | /** 78 | * This is not really lazy hooking as class must be loaded to use reflections. 79 | * 80 | * @param class-string $className 81 | */ 82 | protected function lazyHookAllMethods( 83 | string $className, 84 | int $defaultPriority = 10, 85 | ?callable $injector = null 86 | ): void { 87 | $classReflection = new ReflectionClass($className); 88 | // Look for hook tag in all public methods. 89 | foreach ($classReflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { 90 | // Do not hook constructor. 91 | if ($method->isConstructor()) { 92 | continue; 93 | } 94 | $hookDetails = $this->getMetadata((string)$method->getDocComment(), $defaultPriority); 95 | if ($hookDetails === null) { 96 | continue; 97 | } 98 | 99 | add_filter( 100 | $hookDetails['tag'], 101 | $this->generateClosureWithInjector($hookDetails['tag'], [$className, $method->name], $injector), 102 | $hookDetails['priority'], 103 | $method->getNumberOfParameters() 104 | ); 105 | } 106 | } 107 | 108 | protected function unhook( 109 | string $actionTag, 110 | callable $callback, 111 | int $priority 112 | ): void { 113 | $id = $this->buildUniqueId($actionTag, $callback); 114 | if (! array_key_exists($id, $this->callablesAdded)) { 115 | return; 116 | } 117 | 118 | remove_filter( 119 | $actionTag, 120 | $this->callablesAdded[$id], 121 | $priority 122 | ); 123 | unset($this->callablesAdded[$id]); 124 | } 125 | 126 | // phpcs:disable NeutronStandard.Functions.TypeHint.NoReturnType 127 | 128 | protected function generateClosure(string $actionTag, callable $callback): Closure 129 | { 130 | $id = $this->buildUniqueId($actionTag, $callback); 131 | $this->callablesAdded[$id] = static function (...$args) use ($callback) { 132 | return call_user_func_array($callback, $args); 133 | }; 134 | 135 | return $this->callablesAdded[$id]; 136 | } 137 | 138 | protected function generateClosureWithFileLoad(string $actionTag, callable $callback, string $filePath): Closure 139 | { 140 | $id = $this->buildUniqueId($actionTag, $callback); 141 | $this->callablesAdded[$id] = static function (...$args) use ($filePath, $callback) { 142 | require_once $filePath; 143 | 144 | return call_user_func_array($callback, $args); 145 | }; 146 | 147 | return $this->callablesAdded[$id]; 148 | } 149 | 150 | protected function generateClosureWithInjector(string $actionTag, callable $callback, ?callable $injector): Closure 151 | { 152 | if (! is_array($callback)) { 153 | throw new \InvalidArgumentException( 154 | sprintf('Callable is not an array: %s', var_export($callback, true)) 155 | ); 156 | } 157 | 158 | $id = $this->buildUniqueId($actionTag, $callback); 159 | $this->callablesAdded[$id] = $injector === null 160 | ? static function (...$args) use ($callback) { 161 | return call_user_func_array($callback, $args); 162 | } 163 | : static function (...$args) use ($injector, $callback) { 164 | $instance = call_user_func($injector, $callback[0]); 165 | 166 | return call_user_func_array([$instance, $callback[1]], $args); 167 | }; 168 | 169 | return $this->callablesAdded[$id]; 170 | } 171 | 172 | protected function buildUniqueId(string $actionTag, callable $callback): string 173 | { 174 | return sprintf('%s/%s', $actionTag, _wp_filter_build_unique_id('', $callback, 0)); 175 | } 176 | } 177 | // @TODO Measurements: w/o OPcache, OPcache with file read, OPcache without file read 178 | // @TODO Add tests, remove_action, usage as filter with returned value, 179 | // one callable hooked to many action tags then removed 180 | -------------------------------------------------------------------------------- /src/HookProxyListed.php: -------------------------------------------------------------------------------- 1 | 7 | * @license https://opensource.org/licenses/MIT MIT 8 | * @link https://github.com/szepeviktor/SentencePress 9 | */ 10 | 11 | declare(strict_types=1); 12 | 13 | namespace SzepeViktor\SentencePress; 14 | 15 | use Closure; 16 | use ReflectionClass; 17 | use ReflectionMethod; 18 | 19 | use function _wp_filter_build_unique_id; 20 | use function add_filter; 21 | use function current_filter; 22 | use function remove_filter; 23 | 24 | /** 25 | * Implement lazy hooking. 26 | * @phpstan-ignore trait.unused 27 | */ 28 | trait HookProxyListed 29 | { 30 | use HookAnnotation; 31 | 32 | /** @var array> */ 33 | protected $callablesAdded; 34 | 35 | /** @var list */ 36 | protected $currentCallables; 37 | 38 | /** 39 | * @return mixed 40 | * 41 | * phpcs:disable NeutronStandard.Functions.TypeHint.NoReturnType 42 | */ 43 | public function receiver(...$args) 44 | { 45 | // @FIXME Multiple (priority) hooking of receiver is not possible! 46 | $this->currentCallables = $this->callablesAdded[current_filter()]; 47 | 48 | return call_user_func_array('TODO', $args); 49 | } 50 | 51 | protected function lazyHookFunction( 52 | string $actionTag, 53 | callable $callback, 54 | int $priority, 55 | int $argumentCount, 56 | string $filePath 57 | ): void { 58 | $this->callablesAdded[$actionTag][$priority] = [ 59 | 'callback' => $callback, 60 | 'argumentCount' => $argumentCount, 61 | 'filePath' => $filePath, 62 | ]; 63 | add_filter( 64 | $actionTag, 65 | [$this, 'receiver'], 66 | $priority, 67 | $argumentCount 68 | ); 69 | } 70 | 71 | protected function lazyHookStaticMethod( 72 | string $actionTag, 73 | callable $callback, 74 | int $priority, 75 | int $argumentCount 76 | ): void { 77 | add_filter( 78 | $actionTag, 79 | $this->generateClosure($actionTag, $callback), 80 | $priority, 81 | $argumentCount 82 | ); 83 | } 84 | 85 | protected function lazyHookMethod( 86 | string $actionTag, 87 | callable $callback, 88 | int $priority, 89 | int $argumentCount, 90 | ?callable $injector = null 91 | ): void { 92 | add_filter( 93 | $actionTag, 94 | $this->generateClosureWithInjector($actionTag, $callback, $injector), 95 | $priority, 96 | $argumentCount 97 | ); 98 | } 99 | 100 | /** 101 | * This is not really lazy hooking as class must be loaded to use reflections. 102 | * 103 | * @param class-string $className 104 | */ 105 | protected function lazyHookAllMethods( 106 | string $className, 107 | int $defaultPriority = 10, 108 | ?callable $injector = null 109 | ): void { 110 | $classReflection = new ReflectionClass($className); 111 | // Look for hook tag in all public methods. 112 | foreach ($classReflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { 113 | // Do not hook constructor. 114 | if ($method->isConstructor()) { 115 | continue; 116 | } 117 | $hookDetails = $this->getMetadata((string)$method->getDocComment(), $defaultPriority); 118 | if ($hookDetails === null) { 119 | continue; 120 | } 121 | 122 | add_filter( 123 | $hookDetails['tag'], 124 | $this->generateClosureWithInjector($hookDetails['tag'], [$className, $method->name], $injector), 125 | $hookDetails['priority'], 126 | $method->getNumberOfParameters() 127 | ); 128 | } 129 | } 130 | 131 | protected function unhook( 132 | string $actionTag, 133 | callable $callback, 134 | int $priority 135 | ): void { 136 | $id = $this->buildUniqueId($actionTag, $callback); 137 | if (! array_key_exists($id, $this->callablesAdded)) { 138 | return; 139 | } 140 | 141 | remove_filter( 142 | $actionTag, 143 | $this->callablesAdded[$id], 144 | $priority 145 | ); 146 | unset($this->callablesAdded[$id]); 147 | } 148 | 149 | // phpcs:disable NeutronStandard.Functions.TypeHint.NoReturnType 150 | 151 | protected function generateClosure(string $actionTag, callable $callback): Closure 152 | { 153 | $id = $this->buildUniqueId($actionTag, $callback); 154 | $this->callablesAdded[$id] = static function (...$args) use ($callback) { 155 | return call_user_func_array($callback, $args); 156 | }; 157 | 158 | return $this->callablesAdded[$id]; 159 | } 160 | 161 | protected function generateClosureWithFileLoad(string $actionTag, callable $callback, string $filePath): Closure 162 | { 163 | $id = $this->buildUniqueId($actionTag, $callback); 164 | $this->callablesAdded[$id] = static function (...$args) use ($filePath, $callback) { 165 | require_once $filePath; 166 | 167 | return call_user_func_array($callback, $args); 168 | }; 169 | 170 | return $this->callablesAdded[$id]; 171 | } 172 | 173 | protected function generateClosureWithInjector(string $actionTag, callable $callback, ?callable $injector): Closure 174 | { 175 | if (! is_array($callback)) { 176 | throw new \InvalidArgumentException( 177 | sprintf('Callable is not an array: %s', var_export($callback, true)) 178 | ); 179 | } 180 | 181 | $id = $this->buildUniqueId($actionTag, $callback); 182 | $this->callablesAdded[$id] = $injector === null 183 | ? static function (...$args) use ($callback) { 184 | return call_user_func_array($callback, $args); 185 | } 186 | : static function (...$args) use ($injector, $callback) { 187 | $instance = call_user_func($injector, $callback[0]); 188 | 189 | return call_user_func_array([$instance, $callback[1]], $args); 190 | }; 191 | 192 | return $this->callablesAdded[$id]; 193 | } 194 | 195 | protected function buildUniqueId(callable $callback): string 196 | { 197 | return _wp_filter_build_unique_id('', $callback, 0); 198 | } 199 | } 200 | // TODO Measurements: w/o OPcache, OPcache with file read, OPcache without file read 201 | // TODO Add tests, remove_action, usage as filter with returned value, 202 | // one callable hooked to many action tags then removed 203 | --------------------------------------------------------------------------------