$publishedSet */ public static function forPublishedSet(iterable $publishedSet, RoleRegistry $roles): RequestContext { $withTerms = false; $withRoles = false; foreach ($publishedSet as $optin) { $withTerms = $withTerms || $optin->targeting->usesType(TargetingType::Term); $withRoles = $withRoles || array_filter(\WConvert\Rules\DisplayPlan::rules($optin->payload['display_rules'] ?? []), static fn ($rule) => $rule['type'] === 'role') !== []; // Nothing further to learn. The set is walked on every uncached // page load, so it stops as soon as both answers are in. if ($withTerms && $withRoles) { break; } } return self::current($withTerms, $withRoles ? $roles : null); } /** * **Both parameters are required, and neither defaults to the complete * context.** * * A `$withTerms = true` beside a `$roles = null` would default one half to * *ask everything* and the other to *ask nothing*, so a caller that * supplied neither would silently refuse every role-targeted Optin — which * is precisely the silent, site-wide failure this predicate is held apart * to prevent, arriving through a default argument instead of through a * list. */ public static function current(bool $withTerms, ?RoleRegistry $roles): RequestContext { $isSingular = is_singular(); $postId = $isSingular ? get_queried_object_id() : 0; return new RequestContext( path: self::path(), isSingular: $isSingular, postId: $postId > 0 ? $postId : null, postType: $isSingular ? (get_post_type() ?: null) : null, archivePostType: self::archivePostType(), termIds: $withTerms ? self::termIds($isSingular, $postId) : [], isLoggedIn: is_user_logged_in(), roles: $roles === null ? [] : $roles->held(), ); } /** * The request path, relative to the site root and without its query * string. * * Site-relative because a merchant on a subdirectory install thinks in * `/pricing`, not `/blog/pricing`. Query-free because the path is the * full-page cache's key: a rule that varied on `?utm_source=` would make * the payload vary on it too, and the cache would serve one visitor's * payload to the next. */ private static function path(): string { // esc_url_raw() rather than sanitize_text_field(), which strips // percent-encoded octets and would turn a non-ASCII path into a // different path. $uri = isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '/'; $path = (string) (wp_parse_url($uri, PHP_URL_PATH) ?: '/'); $home = (string) (wp_parse_url(home_url(), PHP_URL_PATH) ?: ''); $home = rtrim($home, '/'); if ($home !== '' && str_starts_with($path, $home)) { $path = substr($path, strlen($home)); } return $path === '' ? '/' : $path; } /** * The post type whose archive this is, or null. * * `is_home()` is folded in on purpose: WordPress registers no post type * archive for `post`, so the blog index is the `post` archive by every * reading except the API's, and a merchant choosing "Posts archive" means * that page. */ private static function archivePostType(): ?string { if (is_post_type_archive()) { $type = get_query_var('post_type'); if (is_array($type)) { $type = reset($type); } return is_string($type) && $type !== '' ? $type : null; } return is_home() ? 'post' : null; } /** * Every term on this request — the queried term on a term archive, and the * terms attached to the post on a singular request. * * One field, because `term:` is one rule: a merchant choosing "News" means * the category page AND the posts in it. * * @return list */ private static function termIds(bool $isSingular, int $postId): array { if (is_category() || is_tag() || is_tax()) { $term = get_queried_object(); return $term instanceof \WP_Term ? [$term->term_id] : []; } if (!$isSingular || $postId <= 0) { return []; } $ids = []; foreach (get_object_taxonomies((string) get_post_type($postId)) as $taxonomy) { $terms = get_the_terms($postId, $taxonomy); if (!is_array($terms)) { continue; } foreach ($terms as $term) { $ids[] = (int) $term->term_id; } } return array_values(array_unique($ids)); } }