, "sub": , "value": } * * Targets: * entire the whole site * front the front page * post_type singular views of `sub` (post type) and its archive * singular singular views (of `sub`, or of any post type when empty) * specific one post (`sub` post type, `value` post id) * archive archives (of post type `sub`, or any archive when empty; the blog index counts as the `post` archive) * taxonomy term archives of taxonomy `sub` * term one term (`sub` taxonomy, `value` term id or slug): its archive and singular posts in that term * author author archives (one author when `value` is a user id) * date | search | 404 * * matches() returns a priority score: 0 when no include rule matches or any exclude rule matches (excludes veto), * otherwise the highest score among the matching include rules. More specific rules score higher: * specific > in-term singular > singular of a type > singular > term archive > taxonomy archive > post type archive * > author/date/search/404 > any archive > front > entire. * * The request is described by a plain context array (context() builds it from the WordPress conditionals) so the * engine is testable without WordPress. */ final class Conditions { public const TARGETS = ['entire', 'front', 'post_type', 'singular', 'specific', 'archive', 'taxonomy', 'term', 'author', 'date', 'search', '404']; public const SCORE_ENTIRE = 10; public const SCORE_FRONT = 20; public const SCORE_ARCHIVE_ANY = 25; public const SCORE_SPECIAL = 30; // author / date / search / 404 public const SCORE_AUTHOR_ONE = 35; public const SCORE_POST_TYPE_ARCHIVE = 40; public const SCORE_TAXONOMY = 50; public const SCORE_TERM_ARCHIVE = 60; public const SCORE_SINGULAR_ANY = 65; public const SCORE_SINGULAR_TYPE = 70; public const SCORE_IN_TERM = 80; public const SCORE_SPECIFIC = 100; /** * Known targets: the built-in ones plus those added through filter `zadex/theme/condition_targets` * (WooCommerce: product, product_in_cat, shop ...). Extra targets are scored by filter * `zadex/theme/condition_score` (int $score, array $rule, array $context). * * @return string[] */ public static function targets(): array { $targets = apply_filters('zadex/theme/condition_targets', self::TARGETS); return is_array($targets) ? array_values(array_unique(array_filter($targets, 'is_string'))) : self::TARGETS; } /** * @param array> $rules * @param array|null $context request description (null = current WordPress request) */ public static function matches(array $rules, ?array $context = null): int { $context = $context ?? self::context(); $best = 0; foreach (self::sanitize($rules) as $rule) { $score = self::score($rule, $context); if ($score <= 0) { continue; } if ($rule['mode'] === 'exclude') { return 0; } $best = max($best, $score); } return $best; } /** Score of a single rule against the context (mode is ignored). */ public static function score(array $rule, array $c): int { $c += self::empty_context(); // not array_merge: it would renumber the '404' key $sub = (string) ($rule['sub'] ?? ''); $value = $rule['value'] ?? ''; switch ($rule['target'] ?? '') { case 'entire': return self::SCORE_ENTIRE; case 'front': return $c['front'] ? self::SCORE_FRONT : 0; case 'post_type': if ($sub === '') { return 0; } if ($c['singular'] && $c['post_type'] === $sub) { return self::SCORE_SINGULAR_TYPE; } return $c['archive'] && $c['archive_post_type'] === $sub ? self::SCORE_POST_TYPE_ARCHIVE : 0; case 'singular': if (!$c['singular']) { return 0; } if ($sub === '') { return self::SCORE_SINGULAR_ANY; } return $c['post_type'] === $sub ? self::SCORE_SINGULAR_TYPE : 0; case 'specific': if (!$c['singular'] || (int) $value <= 0 || (int) $c['post_id'] !== (int) $value) { return 0; } return $sub === '' || $c['post_type'] === $sub ? self::SCORE_SPECIFIC : 0; case 'archive': if (!$c['archive']) { return 0; } if ($sub === '') { return self::SCORE_ARCHIVE_ANY; } return $c['archive_post_type'] === $sub ? self::SCORE_POST_TYPE_ARCHIVE : 0; case 'taxonomy': return $sub !== '' && $c['archive'] && $c['taxonomy'] === $sub ? self::SCORE_TAXONOMY : 0; case 'term': if ($sub === '' || $value === '' || $value === 0) { return 0; } if ($c['archive'] && $c['taxonomy'] === $sub && self::term_is($value, $c['term_id'], $c['term_slug'])) { return self::SCORE_TERM_ARCHIVE; } if ($c['singular'] && isset($c['terms'][$sub]) && is_array($c['terms'][$sub])) { foreach ($c['terms'][$sub] as $term) { if (is_array($term) && self::term_is($value, $term['id'] ?? 0, $term['slug'] ?? '')) { return self::SCORE_IN_TERM; } } } return 0; case 'author': if (!$c['author']) { return 0; } if ($value === '' || (int) $value === 0) { return self::SCORE_SPECIAL; } return (int) $c['author_id'] === (int) $value ? self::SCORE_AUTHOR_ONE : 0; case 'date': return $c['date'] ? self::SCORE_SPECIAL : 0; case 'search': return $c['search'] ? self::SCORE_SPECIAL : 0; case '404': return $c['404'] ? self::SCORE_SPECIAL : 0; } return in_array($rule['target'] ?? '', self::TARGETS, true) ? 0 : max(0, (int) apply_filters('zadex/theme/condition_score', 0, $rule, $c)); } private static function term_is($value, $id, $slug): bool { if (is_numeric($value)) { return (int) $value > 0 && (int) $value === (int) $id; } return is_string($value) && $value !== '' && $value === (string) $slug; } /** @return array */ public static function empty_context(): array { return [ 'front' => false, 'singular' => false, 'post_type' => '', 'post_id' => 0, 'terms' => [], 'archive' => false, 'archive_post_type' => '', 'taxonomy' => '', 'term_id' => 0, 'term_slug' => '', 'author' => false, 'author_id' => 0, 'date' => false, 'search' => false, '404' => false, ]; } /** Describes the current WordPress request (filter `zadex/theme/condition_context` adds keys, e.g. `shop`). */ public static function context(): array { $c = self::base_context(); $filtered = apply_filters('zadex/theme/condition_context', $c); return is_array($filtered) ? $filtered + $c : $c; } private static function base_context(): array { $c = self::empty_context(); $c['front'] = is_front_page(); $c['search'] = is_search(); $c['404'] = is_404(); if (is_singular()) { $post_id = (int) get_queried_object_id(); if ($post_id <= 0 && isset($GLOBALS['post']) && is_object($GLOBALS['post']) && !empty($GLOBALS['post']->ID)) { $post_id = (int) $GLOBALS['post']->ID; } $c['singular'] = true; $c['post_id'] = $post_id; $c['post_type'] = (string) get_post_type($post_id); $terms = []; foreach (get_object_taxonomies($c['post_type']) as $tax) { $list = get_the_terms($post_id, $tax); if (is_array($list)) { foreach ($list as $term) { $terms[$tax][] = ['id' => (int) $term->term_id, 'slug' => (string) $term->slug]; } } } $c['terms'] = $terms; return $c; } if (is_archive() || is_home()) { $c['archive'] = true; if (is_home()) { $c['archive_post_type'] = 'post'; } elseif (is_post_type_archive()) { $pt = get_query_var('post_type'); $c['archive_post_type'] = is_array($pt) ? (string) reset($pt) : (string) $pt; } elseif (is_category() || is_tag() || is_tax()) { $term = get_queried_object(); if (is_object($term) && isset($term->taxonomy)) { $c['taxonomy'] = (string) $term->taxonomy; $c['term_id'] = (int) $term->term_id; $c['term_slug'] = (string) $term->slug; $tax = get_taxonomy($c['taxonomy']); $types = $tax && !empty($tax->object_type) ? (array) $tax->object_type : []; $c['archive_post_type'] = count($types) === 1 ? (string) $types[0] : ''; } } elseif (is_author()) { $c['author'] = true; $c['author_id'] = (int) get_queried_object_id(); $c['archive_post_type'] = 'post'; } elseif (is_date()) { $c['date'] = true; $c['archive_post_type'] = 'post'; } } return $c; } /** * Normalizes a list of rules: unknown targets are dropped, mode defaults to include, `sub` becomes a key, * `value` an int (ids) or a sanitized slug. * * @return array */ public static function sanitize(array $rules): array { $out = []; foreach ($rules as $rule) { if (!is_array($rule)) { continue; } $target = is_string($rule['target'] ?? null) ? $rule['target'] : ''; if (!in_array($target, self::targets(), true)) { continue; } $value = $rule['value'] ?? ''; if (is_int($value) || (is_string($value) && ctype_digit($value))) { $value = (int) $value; } elseif (is_string($value)) { $value = sanitize_title($value); } else { $value = ''; } $out[] = [ 'mode' => ($rule['mode'] ?? '') === 'exclude' ? 'exclude' : 'include', 'target' => $target, 'sub' => is_string($rule['sub'] ?? null) ? sanitize_key($rule['sub']) : '', 'value' => $value, ]; } return $out; } /** One-line human summary for lists ("Entire site", "Singular: post, not 404 page" ...). */ public static function summary(array $rules): string { $rules = self::sanitize($rules); if (!$rules) { return __('No conditions', 'zadex'); } $labels = self::target_labels(); $parts = []; foreach ($rules as $rule) { $text = $labels[$rule['target']] ?? $rule['target']; $detail = array_filter([(string) $rule['sub'], $rule['value'] !== '' && $rule['value'] !== 0 ? '#' . $rule['value'] : '']); if ($detail) { $text .= ': ' . implode(' ', $detail); } $parts[] = $rule['mode'] === 'exclude' ? sprintf(/* translators: excluded condition */ __('not %s', 'zadex'), $text) : $text; } return implode(', ', $parts); } /** @return array */ public static function target_labels(): array { return [ 'entire' => __('Entire site', 'zadex'), 'front' => __('Front page', 'zadex'), 'post_type' => __('Post type', 'zadex'), 'singular' => __('Singular', 'zadex'), 'specific' => __('Specific post', 'zadex'), 'archive' => __('Archive', 'zadex'), 'taxonomy' => __('Taxonomy archive', 'zadex'), 'term' => __('Term', 'zadex'), 'author' => __('Author archive', 'zadex'), 'date' => __('Date archive', 'zadex'), 'search' => __('Search results', 'zadex'), '404' => __('404 page', 'zadex'), ] + (array) apply_filters('zadex/theme/condition_labels', []); } }