tag, not the bare URL. add_filter( 'seopress_titles_canonical', array( $this, 'filter_canonical_tag' ) ); } /** * Attributes optimisers honour: Cloudflare Rocket Loader, WP Rocket, * Autoptimize, WP Fastest Cache, mod_pagespeed, and the generic pair * several others read. A boolean true prints a bare attribute. * * @return array */ private function optimizer_attributes() { return array( 'data-cfasync' => 'false', 'nowprocket' => true, 'data-noptimize' => true, 'data-no-optimize' => '1', 'data-no-defer' => '1', 'data-wpfc-render' => 'false', 'data-pagespeed-no-defer' => true, ); } /** * The handles of the head's inline style rules (print_style()): the * anti-flicker rule, the no-JavaScript rule, the rule for a visitor the * runtime is off for, the visual tests' hiding rule and the idle rule. */ const HEAD_STYLES = array( 'improveab-hide', 'improveab-nojs', 'improveab-off', 'improveab-visual-hide', 'improveab-idle' ); /** * Print one of the head's inline style rules where it is called (the * top of the head, before the page paints), the way WordPress prints * any inline style: a handle with no file, the rule as its inline style * (wp_add_inline_style()), and wp_print_styles() for that handle alone. * The tag, its id ("{$id}-inline-css") and its escaping are WordPress's * own, so a tool that handles the inline styles WordPress prints (a * nonce-based Content Security Policy, a CSS optimiser's exclusions) * handles these too. * * @param string $id Rule name, one of HEAD_STYLES. * @param string $css Rule text, already escaped or sanitized by the caller. */ private function print_style( $id, $css ) { wp_register_style( $id, false, array(), IMPROVEAB_VERSION ); wp_add_inline_style( $id, $css ); wp_print_styles( $id ); } /** * Keep the head's inline style rules out of a CSS optimiser's combined * file (array form: LiteSpeed Cache matches each entry against the * whole tag). Folded into a stylesheet the optimiser loads without * blocking the first paint, the anti-flicker, failsafe and idle rules * would arrive after it, with every version of a tested element on * screen until they do. * * @param mixed $list Current exclusions. * @return array */ public function exclude_styles( $list ) { $ids = array(); foreach ( self::HEAD_STYLES as $id ) { $ids[] = $id . '-inline-css'; } return array_merge( is_array( $list ) ? $list : array(), $ids ); } /** * The same for Autoptimize (comma form, matched against the whole tag). * * @param mixed $csv Current exclusions. * @return string */ public function exclude_styles_csv( $csv ) { $csv = rtrim( is_string( $csv ) ? $csv : '', ', ' ); return ( '' !== $csv ? $csv . ',' : '' ) . implode( ',', $this->exclude_styles( array() ) ); } /** * Mark the plugin's inline head scripts and its config for optimisers. * * @param array $attributes Attributes of the tag being printed. * @param string $data Script contents. * @return array */ public function inline_attributes( $attributes, $data ) { if ( isset( $attributes['id'] ) && in_array( $attributes['id'], array( 'improveab-pending', 'improveab-redirect', 'improveab-config' ), true ) ) { $attributes = array_merge( $attributes, $this->optimizer_attributes() ); } return $attributes; } /** * Mark the runtime's script tag for optimisers. * * @param string $tag The script tag. * @param string $handle Script handle. * @return string */ public function runtime_tag( $tag, $handle ) { if ( 'improveab-runtime' !== $handle ) { return $tag; } $attrs = ''; foreach ( $this->optimizer_attributes() as $name => $value ) { $attrs .= true === $value ? ' ' . $name : ' ' . $name . '="' . esc_attr( $value ) . '"'; } return str_replace( ' src=', $attrs . ' src=', $tag ); } /** * Add the plugin's scripts to an optimiser's exclusion list (array form). * * @param mixed $list Current exclusions. * @return array */ public function exclude_from_optimizers( $list ) { return array_merge( is_array( $list ) ? $list : array(), array( 'improveab-ab-testing/assets/js/runtime.js', 'improveab-pending', 'improveab-redirect', 'improveab-pending' ) ); } /** * Add the plugin's scripts to an optimiser's exclusion list (comma form). * * @param mixed $csv Current exclusions. * @return string */ public function exclude_csv( $csv ) { $csv = rtrim( (string) $csv, ', ' ); return ( '' !== $csv ? $csv . ',' : '' ) . 'improveab-ab-testing/assets/js/runtime.js,improveab-pending,improveab-redirect'; } /** * Add the runtime handle to an optimiser's exclusion list (handle form). * * @param mixed $handles Current exclusions. * @return array */ public function exclude_handles( $handles ) { $handles = is_array( $handles ) ? $handles : array(); $handles[] = 'improveab-runtime'; return $handles; } /** * What the visitor stores hold for this request, read once * (ImproveAB_Tests::front()), plus the test a manager previews when it * does not run: its pieces built for this request alone. * * @return array */ private function front() { if ( null !== $this->running ) { return $this->running; } $front = ImproveAB_Tests::front(); $front = array_merge( array( 'n' => 0, 'slugs' => array(), 'kept' => '', 'other' => '', 'vis' => array(), 'cfg' => array(), 'url' => array(), 'buy' => array(), 'idle' => '', 'scan' => false ), is_array( $front ) ? $front : array() ); $front['preview'] = null; // A manager previewing a test that does not run (a draft checked // before launch, a paused or finished test): delivered for this one // request as if it ran, so the preview link on its results page shows // the version it names. The runtime treats a preview as forced: it // shows that version and never assigns, counts or attaches goals. $pslug = $this->preview_slug(); if ( '' !== $pslug && improveab_can_manage() && ! in_array( $pslug, (array) $front['slugs'], true ) ) { $test = ImproveAB_Tests::get_by_slug( $pslug ); if ( $test ) { $test['preview'] = true; $front['preview'] = array( 'test' => $test, 'pieces' => self::pieces( $test ), ); } } $this->running = $front; return $front; } /** * The slug of the test a manager is previewing from one of the plugin's * own preview links (improveab_preview_request()), or ''. * * @return string */ private function preview_slug() { $preview = improveab_preview_request(); return $preview ? $preview['slug'] : ''; } /** * One running or served test's share of every visitor page, built once * per cache generation for the visitor store (ImproveAB_Tests::front()), * so a page view joins finished pieces instead of building them: * * - kept / other: an element test's hide selectors, the version kept * when no script runs (the original, or the served winner) and the * others; * - vis: a visual test's selector, sanitised again here, where it is * printed: one a migration or a restore stored must not close the * head's style element; * - cfg: the runtime config's entry as JSON, and for a visual test that * has a page scope the trimmed entry pages outside it get; * - url: a URL test's redirect entry and the paths it names; * - event: what /event checks an event against. * * @param array $test Test with variants (`serve` set for a served winner). * @return array */ public static function pieces( $test ) { $serve = isset( $test['serve'] ) ? (string) $test['serve'] : ''; $pages = isset( $test['targeting']['pages'] ) && is_array( $test['targeting']['pages'] ) ? $test['targeting']['pages'] : array(); $out = array( 'kept' => array(), 'other' => array(), 'vis' => null, 'cfg' => null, 'url' => null, 'event' => null, ); if ( 'element' === $test['type'] ) { $keep = '' !== $serve ? $serve : 'default'; foreach ( $test['variants'] as $v ) { if ( empty( $v['class_name'] ) || ! preg_match( '/^[A-Za-z0-9_-]+$/', $v['class_name'] ) ) { continue; } $sel = '.' . $v['class_name'] . self::HIDE_WEIGHT; if ( $keep === (string) $v['variant_key'] ) { $out['kept'][] = $sel; } else { $out['other'][] = $sel; } } } $selector = 'visual' === $test['type'] ? ImproveAB_Tests::sanitize_selector( self::visual_selector( $test ) ) : ''; if ( '' !== $selector ) { $out['vis'] = array( 's' => (string) $test['slug'], 'sel' => $selector, 'pg' => $pages, 'serve' => '' !== $serve, ); } $scoped = ! empty( $pages['mode'] ) && 'all' !== $pages['mode'] && ! empty( $pages['patterns'] ); $out['cfg'] = array( 's' => (string) $test['slug'], 'j' => (string) wp_json_encode( self::config_entry( $test, $selector, false ), JSON_HEX_TAG ), // A visual variant never renders outside the test's page scope (the // runtime checks scope at display time), so such pages carry only // what goal tracking needs: no markup. Previews and served // winners render anywhere and keep it all. 't' => 'visual' === $test['type'] && '' === $serve && $scoped ? (string) wp_json_encode( self::config_entry( $test, $selector, true ), JSON_HEX_TAG ) : '', 'pg' => $pages, // A page-visit goal on one exact page, and a page rule that names // a page by its query, need the query vars that pick what // WordPress shows (the config's `qv`), which a page without one // does not carry. 'q' => ( 'url' === $test['goal_type'] && isset( $test['goal_config']['match'] ) && 'exact' === $test['goal_config']['match'] ) || ImproveAB_Tests::pages_have_query( $pages ), ); if ( 'url' === $test['type'] ) { $out['url'] = self::redirect_entry( $test ); } // The variant pages' hosts as /event compares them with the Origin a // browser sends: in the browser's form (an international name as // xn--, whichever way the address was typed). $hosts = array(); $home = improveab_ascii_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); foreach ( $test['variants'] as $v ) { $vhost = improveab_ascii_host( (string) wp_parse_url( (string) $v['url'], PHP_URL_HOST ) ); if ( 'url' === $test['type'] && '' !== $vhost && $vhost !== $home ) { $hosts[] = improveab_root_domain( $vhost ); } } $out['event'] = array( 'id' => (int) $test['id'], 'k' => array_map( 'strval', wp_list_pluck( $test['variants'], 'variant_key' ) ), 'h' => array_values( array_unique( $hosts ) ), ); return $out; } /** * A test's entry in the runtime config (#improveab-config), the shape * runtime.js reads (CONTRACTS 10.1: it only grows). * * @param array $test Test with variants. * @param string $selector A visual test's selector, sanitised. * @param bool $trim Leave out the markup (a visual test on a * page outside its scope). * @return array */ private static function config_entry( $test, $selector, $trim ) { $variants = array(); foreach ( $test['variants'] as $v ) { $vitem = array( 'key' => $v['variant_key'], 'class' => $v['class_name'], 'url' => (string) $v['url'], ); // Visual tests: ship only what the runtime applies (the // element selector and the replacement text or inline HTML). if ( 'visual' === $test['type'] && ! empty( $v['edits'] ) && is_array( $v['edits'] ) ) { $vitem['edits'] = array( 'selector' => $selector, ); if ( isset( $v['edits']['text'] ) ) { $vitem['edits']['text'] = (string) $v['edits']['text']; } // The markup is filtered again where it is printed, as the // selector is: a row can reach the table another way than a // save (a restore, a search and replace, an // earlier release with a looser filter), and what this prints // is what every visitor's browser runs. A second pass over // what a save stored changes nothing. if ( ! $trim && isset( $v['edits']['mode'] ) && 'html' === $v['edits']['mode'] && isset( $v['edits']['html'] ) ) { $html = ImproveAB_Tests::clean_variant_html( $v['edits']['html'] ); if ( '' !== $html ) { $vitem['edits']['mode'] = 'html'; $vitem['edits']['html'] = $html; } } } $variants[] = $vitem; } $entry = array( 'slug' => $test['slug'], // Tells this test apart from an earlier one with the same slug // (deleted and created again), whose stored assignment and // counted flags a returning visitor still carries. 'uid' => self::test_uid( $test ), 'type' => $test['type'], 'variants' => $variants, // Where the test runs; every visitor except staff takes part. 'targeting' => array( 'pages' => isset( $test['targeting']['pages'] ) && is_array( $test['targeting']['pages'] ) ? $test['targeting']['pages'] : array() ), 'goal' => array( 'type' => $test['goal_type'], 'config' => is_array( $test['goal_config'] ) ? $test['goal_config'] : array(), ), ); if ( ! empty( $test['serve'] ) ) { $entry['serve'] = $test['serve']; } // A page rule that names a page by its query: a runtime before level // 2 reads the "?" as part of the path, so an "Everywhere except" // rule would run the test on the very page it leaves out. Such a // runtime, still in a page cache, shows the original instead // (CONTRACTS 10.1 rule 5). if ( ImproveAB_Tests::pages_have_query( isset( $test['targeting']['pages'] ) ? $test['targeting']['pages'] : array() ) ) { $entry['rt'] = 2; } return $entry; } /** * A URL test's entry in the redirect script's config, and the paths its * addresses name on this site: the script is printed only on those * pages (redirect_config()). The handoff token for variants on another * host is signed per request (its expiry runs from now). * * @param array $test URL test with variants. * @return array|null */ private static function redirect_entry( $test ) { // Hosts in the form a browser names them: an address pasted from the // browser's bar names an international site in xn-- letters, while // the Home address is kept as it was typed. $home = improveab_ascii_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $entry = ''; $variants = array(); $cross = false; $paths = array(); $local = array(); foreach ( $test['variants'] as $v ) { $url = (string) $v['url']; $variants[] = array( 'key' => $v['variant_key'], 'url' => $url, ); if ( 'default' === $v['variant_key'] ) { $entry = $url; } // A host the browser cannot name is another host, not none. $vhost = (string) wp_parse_url( $url, PHP_URL_HOST ); if ( '' !== $vhost && improveab_ascii_host( $vhost ) !== $home ) { $cross = true; } elseif ( '' !== $url ) { $paths[] = self::normalize_path( $url ); if ( 'default' !== $v['variant_key'] ) { $local[] = $url; } } } if ( '' === $entry ) { return null; // No entry URL configured; skip. } $item = array( 'slug' => $test['slug'], 'uid' => self::test_uid( $test ), 'entry' => $entry, 'variants' => $variants, ); if ( ! empty( $test['serve'] ) ) { $item['serve'] = $test['serve']; } $entry_host = (string) wp_parse_url( $entry, PHP_URL_HOST ); return array( 'item' => $item, 'paths' => array_values( array_unique( $paths ) ), 'cross' => $cross, 'goal' => array( 'type' => $test['goal_type'], 'config' => is_array( $test['goal_config'] ) ? $test['goal_config'] : array(), ), // The canonical: the control, when the original is on this host, // and the variant addresses on this host it is printed on. 'control' => '' === $entry_host || improveab_ascii_host( $entry_host ) === $home ? $entry : '', 'local' => $local, 'serve' => ! empty( $test['serve'] ), ); } /** * Print early head output: hiding rule for element tests, redirect logic * for URL tests, and a canonical tag on recognised variant pages. */ public function head_snippet() { // An editor canvas (the Customizer, a builder's frame) must show every // variant untouched: no hide rule, no anti-flicker gate, no redirect. // The canonical handling further down is for search engines and is // never printed inside an editor anyway. if ( $this->is_editor_preview() ) { return; } $front = $this->front(); $preview = $front['preview'] ? $front['preview']['pieces'] : null; // The same opt-out filter as the runtime: when disabled, skip assignment // and redirect output. Canonical handling below is left in place because // it is for search engines, not visitor tracking. $enabled = (bool) apply_filters( 'improveab_enabled', true ); $settings = ImproveAB_Settings::get(); if ( ! empty( $settings['exclude_logged_in'] ) && is_user_logged_in() ) { $enabled = false; } // Visual (point-and-click) tests hide only the picked element until // the runtime has applied the assigned text, so visitors never see // the original flash first. visibility (not display) keeps layout // stable. Selectors are sanitized on save and again in pieces(). $visual_css = ''; $visuals = (array) $front['vis']; if ( $preview && $preview['vis'] ) { $visuals[] = $preview['vis']; } if ( $enabled && ! empty( $visuals ) ) { $previewing = $this->preview_slug(); foreach ( $visuals as $vis ) { // Outside the test's page scope the runtime never swaps the // element, so hiding it only held back what is often the // page's largest paint until the runtime ran. if ( empty( $vis['serve'] ) && $previewing !== $vis['s'] && ! $this->page_in_scope( array( 'targeting' => array( 'pages' => $vis['pg'] ) ) ) ) { continue; } $visual_css .= 'html.improveab-pending ' . $vis['sel'] . '{visibility:hidden !important}'; } } // Element tests: the variant classes to hide until the runtime // reveals the assigned one, and the classes to hide when no script // will ever run (JavaScript off, or the runtime switched off for this // visitor): everything except the control, or except the served // winner. Listing the classes (rather than a [class*="improveab-"] // wildcard) means an unrelated plugin or theme using a similar prefix // is never hidden by us. Built regardless of $enabled: an excluded // visitor must still see one version, not every variant stacked. $kept = (string) $front['kept']; $other = (string) $front['other']; if ( $preview ) { $kept = implode( ',', array_filter( array_merge( array( $kept ), $preview['kept'] ) ) ); $other = implode( ',', array_filter( array_merge( array( $other ), $preview['other'] ) ) ); } $all = implode( ',', array_filter( array( $kept, $other ) ) ); if ( $enabled && '' !== $all ) { // When the failsafe below has to act (the runtime is slow or // blocked), the page falls back to what no-JavaScript visitors // see, one version, instead of every variant stacked. $this->print_style( 'improveab-hide', esc_html( 'html.improveab-pending ' . str_replace( ',', ',html.improveab-pending ', $all ) ) . '{display:none !important}' . ( '' !== $other ? esc_html( 'html.improveab-failsafe ' . str_replace( ',', ',html.improveab-failsafe ', $other ) ) . '{display:none !important}' : '' ) ); } if ( '' !== $other ) { $nojs_rule = esc_html( $other ) . '{display:none !important}'; if ( $enabled ) { // Without JavaScript nothing assigns or reveals, so a visitor // would see every variant's markup at once. A noscript // wrapper is the one rule that applies exactly then (and to // a blocker that renders noscript content, as NoScript and // uBlock Origin do); WordPress prints the style inside it. echo '' . "\n"; } else { // Runtime off for this visitor (exclude_logged_in, or the // improveab_enabled filter): no script will hide anything, so // the same control-only rule applies unconditionally. $this->print_style( 'improveab-off', $nojs_rule ); // Escaped above. } } if ( '' !== $visual_css ) { // Selectors sanitised on save and in pieces(), and tags stripped // again here, where the rule is printed (as core prints a theme's // Additional CSS): a combinator or a quote must reach the rule as // it is, so esc_html() would break it. $this->print_style( 'improveab-visual-hide', wp_strip_all_tags( $visual_css ) ); } // Tests that exist but are not running (a draft being prepared, a // paused test, a stopped test whose winner is not being served): the // classes are already on the page, so without a rule every version // would show stacked. Visitors see the original instead, until the // test launches or the page is edited. Printed for every visitor, // runtime or not; deleting a test drops its rule, so remove the // markup first. A short rule is printed whole; past // ImproveAB_Tests::IDLE_INLINE_MAX the page carries the selectors of // the classes in its own markup only (idle_scan(), once the page is // rendered), so a site that keeps every old test does not send them // all with every page. $skip = array(); if ( $front['preview'] ) { foreach ( $front['preview']['test']['variants'] as $v ) { $skip[] = (string) $v['class_name']; } } if ( '' !== $front['idle'] ) { $idle = (string) $front['idle']; foreach ( $skip as $cls ) { $idle = str_replace( array( '.' . $cls . self::HIDE_WEIGHT . ',', ',.' . $cls . self::HIDE_WEIGHT, '.' . $cls . self::HIDE_WEIGHT ), array( '', '', '' ), $idle ); } if ( '' !== $idle ) { $this->print_style( 'improveab-idle', esc_html( $idle ) . '{display:none !important}' ); } } elseif ( ! empty( $front['scan'] ) && $this->buffering ) { $this->idle_skip = $skip; $this->print_style( 'improveab-idle', self::IDLE_MARK ); } elseif ( ! empty( $front['scan'] ) ) { // WordPress keeps no buffer around this page (a version before // 6.9, a site that streams its pages, a head printed outside a // template load): every idle class, as before the scan existed. $sel = array(); foreach ( preg_split( '/\s+/', trim( ImproveAB_Tests::front_idle_classes() ) ) as $cls ) { if ( '' !== $cls && ! in_array( $cls, $skip, true ) ) { $sel[] = '.' . $cls . self::HIDE_WEIGHT; } } if ( $sel ) { $this->print_style( 'improveab-idle', esc_html( implode( ',', $sel ) ) . '{display:none !important}' ); } } // Inline scripts go through wp_print_inline_script_tag() so a // nonce-based CSP plugin can attribute them (wp_inline_script_attributes). if ( $enabled && ( '' !== $all || '' !== $visual_css ) ) { // The inline failsafe below lifts the gate even if runtime.js // never executes (blocked by an ad blocker, a consent tool, or a // broken optimizer), so tested elements can never stay hidden: the // page switches to improveab-failsafe, which shows the original of // each element test (one version, not every variant stacked) until // the runtime, if it ever runs, takes over and clears it. The // runtime normally reveals much sooner, and then this finds // nothing left to do. wp_print_inline_script_tag( 'document.documentElement.className+=" improveab-pending";(function(f){if(document.prerendering){document.addEventListener("prerenderingchange",f,{once:true});}else{f();}})(function(){setTimeout(function(){var h=document.documentElement;if(/\bimproveab-pending\b/.test(h.className)){h.className=h.className.replace(/\bimproveab-pending\b/g,"improveab-failsafe");}},' . (int) ( 2 * self::TIMEOUT_MS ) . ');});', array( 'id' => 'improveab-pending' ) ); } // Never inside an editor's preview frame: the redirect would carry // the Customizer or Elementor canvas away from the page being edited. // Only where a URL test's address is this page: the script acts on a // test's entry page alone, and every other page would carry it for // nothing. $redirect = $enabled ? $this->redirect_config() : null; $script = $redirect ? $this->redirect_script() : ''; if ( '' !== $script ) { // Synchronous head script: assigns and redirects before the body paints. wp_print_inline_script_tag( $script . '(' . wp_json_encode( $redirect, JSON_HEX_TAG ) . ');', array( 'id' => 'improveab-redirect' ) ); } // SEO handling on recognised variant URLs while a test runs. Three // modes: canonical (default, Google's recommendation for split tests: // consolidate signals on the original), noindex (keep variants out of // search entirely, through wp_robots(), so the page has one robots // tag; robots() below), or off (the plugin stays out of SEO // completely). The original page is never touched. $control = $this->canonical_target(); if ( $control && 'canonical' === $this->seo_mode() ) { // Printed here only when no SEO plugin prints one: Yoast, Rank // Math, All in One SEO and SEOPress already print theirs, pointed // at the control by filter_canonical(), and a second tag beside it // is noise at best. $seo_plugin = defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || defined( 'AIOSEO_VERSION' ) || defined( 'SEOPRESS_VERSION' ); if ( ! $seo_plugin || has_action( 'wp_head', 'rel_canonical' ) ) { remove_action( 'wp_head', 'rel_canonical' ); echo '' . "\n"; } } } /** * Ask WordPress for the rendered page, when the idle rule is too long to * print whole (ImproveAB_Tests::IDLE_INLINE_MAX): idle_scan() filters * WordPress's template enhancement output buffer (WordPress 6.9), which * starts before the template is included because a filter is on it. The * plugin opens no output buffer of its own. The head carries the * placeholder only once that buffer has started; where it does not, the * head carries every idle class instead (print_head()). */ public function start_idle_scan() { if ( is_admin() || $this->is_editor_preview() ) { return; } $front = $this->front(); if ( empty( $front['scan'] ) ) { return; } add_filter( 'wp_template_enhancement_output_buffer', array( $this, 'idle_scan' ) ); add_action( 'wp_template_enhancement_output_buffer_started', array( $this, 'mark_idle_scan' ) ); } /** * WordPress's template enhancement output buffer has started: the page * will reach idle_scan() whole. */ public function mark_idle_scan() { $this->buffering = true; } /** * Put the idle rule in the rendered page: the selectors of the idle * tests' other versions whose classes the page's body carries (the head * names the running tests' own classes, which are never idle). Markup a * script adds after load is not covered this way; a site whose idle rule * is short gets it whole on every page instead. Runs inside WordPress's * output buffer callback, so it prints nothing. * * @param string $html The page. * @return string */ public function idle_scan( $html ) { $html = (string) $html; $at = strpos( $html, self::IDLE_MARK ); if ( false === $at ) { return $html; } $css = ''; $body = stripos( $html, 'front(); $live = ',' . $front['kept'] . ',' . $front['other'] . ','; $found = array(); foreach ( array_unique( $m[0] ) as $cls ) { if ( false === strpos( $live, ',.' . $cls . self::HIDE_WEIGHT . ',' ) && ! in_array( $cls, $this->idle_skip, true ) ) { $found[] = $cls; } } if ( $found ) { $idle = ImproveAB_Tests::front_idle_classes(); $sel = array(); foreach ( $found as $cls ) { if ( false !== strpos( $idle, ' ' . $cls . ' ' ) ) { $sel[] = '.' . $cls . self::HIDE_WEIGHT; } } if ( $sel ) { $css = esc_html( implode( ',', $sel ) ) . '{display:none !important}'; } } } return substr_replace( $html, $css, $at, strlen( self::IDLE_MARK ) ); } /** * Noindex on a recognised variant page while its test runs, when the * site chose that mode: through WordPress's own robots tag (and the SEO * plugins that print theirs from it), so the page carries one. * * @param array $robots Directives. * @return array */ public function robots( $robots ) { if ( 'noindex' === $this->seo_mode() && $this->canonical_target() ) { $robots = (array) $robots; $robots['noindex'] = true; $robots['follow'] = true; unset( $robots['index'], $robots['max-image-preview'] ); } return $robots; } /** * Rank Math's own robots (it prints them itself): noindex as above. * * @param array $robots Directives, name => value. * @return array */ public function rank_math_robots( $robots ) { if ( 'noindex' === $this->seo_mode() && $this->canonical_target() ) { $robots = (array) $robots; $robots['index'] = 'noindex'; } return $robots; } /** * Build the inline redirect configuration: the URL tests whose entry or a * variant address names this request's path (null when none does). The * script itself checks the query too (same()); matching the path alone * here keeps a page cache that ignores query strings serving the test. * * @return array|null */ private function redirect_config() { $front = $this->front(); $urls = (array) $front['url']; if ( $front['preview'] && $front['preview']['pieces']['url'] ) { $urls[] = $front['preview']['pieces']['url']; } $here = $this->current_path(); if ( empty( $urls ) || '' === $here || $this->is_editor_preview() ) { return null; } $endpoint = rest_url( ImproveAB_REST::NS . '/event' ); $tests = array(); foreach ( $urls as $u ) { if ( ! in_array( $here, (array) $u['paths'], true ) ) { continue; } $item = $u['item']; // For tests whose variant pages live on another domain/subdomain, // attach a signed instruction. The variant site replays it to this // endpoint so the conversion is attributed back here. The token is // verifiable only by this site, so it cannot be forged elsewhere. if ( ! empty( $u['cross'] ) ) { $item['x'] = improveab_sign( array( 's' => $item['slug'], 'g' => $u['goal'], 'e' => $endpoint, 'x' => time() + ( 60 * DAY_IN_SECONDS ), ), 'ho' ); } $tests[] = $item; } if ( empty( $tests ) ) { return null; } return array( 'e' => esc_url_raw( $endpoint ), 'ed' => current_user_can( 'edit_posts' ) ? 1 : 0, 'qv' => implode( ' ', $this->content_query_vars() ), 'tests' => $tests, ); } /** * A test's uid: 12 hex characters of its uuid (CONTRACTS 8.1). Tells * this test apart from an earlier one with the same slug (deleted and * created again), whose stored assignment and counted flags a returning * visitor still carries. * * @param array $test Test row. * @return string */ private static function test_uid( $test ) { return substr( str_replace( '-', '', (string) ( isset( $test['uuid'] ) ? $test['uuid'] : '' ) ), 0, 12 ); } /** * The query vars that pick what WordPress shows: a search, a single * post, page or attachment, an archive (post type, category, tag, * taxonomy term, author, date), a later page of a list, a post or its * comments, the 404 page (?error=404), and the query var of every post * type and taxonomy a visitor can reach (?product=, ?product_cat=). * Under plain permalinks each of those pages sits on the home path, and * under pretty ones a site search does, so a request carrying one that * a test's URL does not name is another page than that URL * (url_is_current(), the redirect script's same(), the picker's * testOnPage()). Parameters that only sort the same page (orderby, * order) are not among them, nor are the endpoints that never print a * page head (feed, embed, robots, sitemaps, a preview). * * @return string[] */ private function content_query_vars() { if ( null !== $this->content_vars ) { return $this->content_vars; } $vars = array( 's', 'p', 'page_id', 'name', 'pagename', 'attachment', 'attachment_id', 'subpost', 'subpost_id', 'post_type', 'cat', 'category_name', 'tag', 'taxonomy', 'term', 'author', 'author_name', 'm', 'year', 'monthnum', 'w', 'day', 'hour', 'minute', 'second', 'paged', 'page', 'cpage', 'error' ); foreach ( get_post_types( array(), 'objects' ) as $type ) { if ( is_string( $type->query_var ) && '' !== $type->query_var && is_post_type_viewable( $type ) ) { $vars[] = $type->query_var; } } foreach ( get_taxonomies( array(), 'objects' ) as $tax ) { if ( is_string( $tax->query_var ) && '' !== $tax->query_var && ! empty( $tax->publicly_queryable ) ) { $vars[] = $tax->query_var; } } $this->content_vars = array_values( array_unique( $vars ) ); return $this->content_vars; } /** * The inline redirect runtime (assets/js/redirect.js), a self-contained * function expression invoked with the redirect config. Kept tiny and fail-safe. Mirrors the * runtime's rules: memoised visitor id, storage probe (no persisting * storage = excluded), tolerant query parsing, and carry() so the entry * page's query string (minus the preview params) and hash travel with * the redirect: ad-click ids and UTM tags must reach the variant page. * Parameters the variant URL names itself are not carried: under plain * permalinks the entry's page_id=6 appended after the variant's * page_id=7 would win server-side and bounce the visitor back forever. * Nor is any query var that picks what WordPress shows (C.qv, from * content_query_vars()): it names the entry, and on a variant without * one (the homepage) it would show the entry again. * A variant on another host is never the page being viewed, whatever its * path: a redesign mirrored on a subdomain keeps the entry's paths, and a * path-only comparison left every visitor assigned to it on the original. * A request is a URL's page when the path matches, every parameter the * URL names is present with its value, and it carries no query var from * C.qv the URL does not name: a homepage entry must not catch a site * search, and under plain permalinks every page (?page_id=, ?p=, ?cat=) * sits on the home path. * Stored assignments follow runtime.js: one the test does not have, or * one stored under another test's uid (deleted and created again under * the same name, so the same slug), is dropped with its flags and the * visitor drawn afresh; the uid is written with every assignment. Who * takes part decides who is assigned, never who keeps a variant: a * visitor assigned before signing in is still sent to it, as runtime.js * keeps them in it (and files their conversions under it). * * @return string */ private function redirect_script() { // A file of its own, printed inline: it has to run in the head before // the page paints, so it cannot wait for a request of its own. Only a // URL test's entry page reads it. $script = file_get_contents( plugin_dir_path( IMPROVEAB_FILE ) . 'assets/js/redirect.js' ); return false === $script ? '' : rtrim( $script ); } /** * The control (canonical) URL if the current request is a non-control * variant page of a running URL test, otherwise empty string. * * @return string */ private function canonical_target() { if ( false !== $this->canonical ) { return (string) $this->canonical; } $this->canonical = ''; if ( '' === $this->current_path() ) { return ''; } foreach ( (array) $this->front()['url'] as $u ) { // Only while the test runs. A served winner sends every visitor // from the control to the winner, and a canonical (or noindex) // on the winner pointing back would contradict that redirect; a // manager's preview of a test that does not run changes nothing. // URLs on another host are not pages of this site: they can // neither be the current page nor a canonical target here // (cross-domain tests emit no canonical, by design). if ( ! empty( $u['serve'] ) || '' === $u['control'] ) { continue; } foreach ( (array) $u['local'] as $url ) { if ( $this->url_is_current( (string) $url ) ) { $this->canonical = (string) $u['control']; break 2; } } } return (string) $this->canonical; } /** * Canonical filter for SEO plugins. * * @param string $canonical Their canonical URL. * @return string */ public function filter_canonical( $canonical ) { if ( 'canonical' !== $this->seo_mode() ) { return $canonical; } $control = $this->canonical_target(); return $control ? $control : $canonical; } /** * Canonical filter for SEO plugins that filter the whole tag * rather than the bare URL (SEOPress). * * @param string $html Their canonical tag. * @return string */ public function filter_canonical_tag( $html ) { if ( 'canonical' !== $this->seo_mode() ) { return $html; } $control = $this->canonical_target(); return $control ? '' : $html; } /** * Resolve the SEO handling mode for variant pages, deriving from the * legacy on/off toggle when the mode was never saved explicitly. * * @return string canonical|noindex|off */ private function seo_mode() { $mode = (string) $this->setting( 'seo_mode', '' ); if ( in_array( $mode, array( 'canonical', 'noindex', 'off' ), true ) ) { return $mode; } return $this->setting( 'seo_canonical', 1 ) ? 'canonical' : 'off'; } /** * Normalise a URL to a comparable path (no host, no query, no trailing slash). * * @param string $url URL or path. * @return string */ private static function normalize_path( $url ) { if ( '' === $url ) { return ''; } // Decoded on both sides: the request path arrives percent-encoded, // while a variant URL with a non-ASCII slug may have been pasted as // the browser showed it. page_in_scope() applies the same rule. $path = rawurldecode( (string) wp_parse_url( $url, PHP_URL_PATH ) ); $path = rtrim( $path, '/' ); return '' === $path ? '/' : $path; } /** * Whether a URL names the current request: the same path (trailing * slash ignored), every query parameter the URL carries present with * the same value, and no query var that picks what WordPress shows * (content_query_vars()) on the request that the URL does not name. * Plain permalinks tell pages apart by query alone (?page_id=12), so * the path is not enough, and a site search or, under plain permalinks, * any page is a request on the home path, so a URL for the homepage * must not claim them; extra parameters on the request (a UTM tag) do * not matter. The redirect script and the picker apply the same rule. * * @param string $url URL to compare. * @return bool */ private function url_is_current( $url ) { $here = $this->current_path(); if ( '' === $url || '' === $here || self::normalize_path( $url ) !== $here ) { return false; } return $this->request_names( (string) wp_parse_url( $url, PHP_URL_QUERY ) ); } /** * Whether this request carries a query's parameters: every one it * names, with the same value (both decoded), and no query var that * picks what WordPress shows (content_query_vars()) that it does not * name. Extra parameters (a UTM tag) and their order do not matter. A * URL test's address names the current page by it (url_is_current()), * and so does a page rule's pattern with a query part * (page_in_scope()); the runtime and the redirect script apply the * same rule. * * @param string $query Query string, without its "?". * @return bool */ private function request_names( $query ) { $want = self::query_pairs( (string) $query, true ); if ( null === $want ) { return false; } $have = $this->request_query(); $named = array(); foreach ( $want as $k => $v ) { if ( ! array_key_exists( $k, $have ) || $have[ $k ] !== $v ) { return false; } $named[ self::query_base( $k ) ] = true; } foreach ( array_keys( $have ) as $k ) { $base = self::query_base( $k ); if ( '' !== $base && ! isset( $named[ $base ] ) && in_array( $base, $this->content_query_vars(), true ) ) { return false; } } return true; } /** * This request's query parameters, read once. * * @return array */ private function request_query() { if ( null === $this->request_query ) { // Through esc_url_raw() like the stored variant URL it is compared with. $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( 'http://x' . wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $this->request_query = self::query_pairs( (string) wp_parse_url( $uri, PHP_URL_QUERY ), false ); } return $this->request_query; } /** * A query's parameters as the runtime and the redirect script read * them (decodeURIComponent() on each side of the first "="): a "+" * stays a plus, a bracketed name is a name of its own, the last of a * repeated name wins. PHP's own reading takes "+" for a space and * folds "a[]" into an array, so a page rule naming "?s=c++" was out * of scope here and in scope in the browser, which counted visitors * under a version the page never carried. * * @param string $query Query string, without its "?". * @param bool $strict Null for the whole query when a pair does not * decode (a rule's own query, which then names * nothing, as in the runtime); otherwise that pair * is left out (a request's). * @return array|null Name => value. */ private static function query_pairs( $query, $strict ) { $out = array(); foreach ( explode( '&', $query ) as $pair ) { if ( '' === $pair ) { continue; } $at = strpos( $pair, '=' ); $name = self::uri_component( false === $at ? $pair : substr( $pair, 0, $at ) ); $val = self::uri_component( false === $at ? '' : substr( $pair, $at + 1 ) ); if ( null === $name || null === $val ) { if ( $strict ) { return null; } continue; } $out[ $name ] = $val; } return $out; } /** * One part of a query decoded as decodeURIComponent() decodes it, or * null where that throws: a "%" without two hex digits after it, or * bytes that are not UTF-8. * * @param string $part Encoded text. * @return string|null */ private static function uri_component( $part ) { if ( preg_match( '/%(?![0-9A-Fa-f]{2})/', $part ) ) { return null; } $text = rawurldecode( $part ); return 1 === preg_match( '//u', $text ) ? $text : null; } /** * A parameter's name without its brackets ("a[]" is "a"), as the * runtime's picksOther() reads it. * * @param string|int $name Parameter name. * @return string */ private static function query_base( $name ) { return (string) preg_replace( '/\[.*$/D', '', (string) $name ); } /** * The current request path, normalised. * * @return string */ private function current_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( 'http://x' . wp_unslash( $_SERVER['REQUEST_URI'] ) ) : 'http://x'; return self::normalize_path( $uri ); } /** * Whether this request is an editor's preview frame: the Customizer, * a post preview, or a page builder's editing canvas. * * Only a signed-in user who can edit opens one. The Customizer's frame * is signed (a changeset and its nonce) and core builds it for a user * who can customize; a post preview shows the saved draft to the user * who can edit that post, and every builder's canvas is opened by its * editor. An anonymous request that carries one of their query flags * (`?preview=true`, `?bricks=1`, `?elementor-preview=1`...) is a visitor, * or a page cache fetching the page, asking for it without its runtime * and its hide rules (every version of every element test at once, the * idle ones too); and what they get is everybody's wherever a cache keys * pages without the query string. * * @return bool */ private function is_editor_preview() { if ( null !== $this->is_editor ) { return $this->is_editor; } $is_editor = is_customize_preview(); if ( ! $is_editor && current_user_can( 'edit_posts' ) ) { $is_editor = $this->in_editing_canvas(); } /** * Filter whether this request is an editor canvas that must carry no * visitor runtime, no hiding and no redirect (a builder this plugin * does not recognise). * * @param bool $is_editor Whether the request is an editor frame. */ $this->is_editor = (bool) apply_filters( 'improveab_is_editor', $is_editor ); return $this->is_editor; } /** * Whether a user who can edit is looking at a post preview or a page * builder's editing canvas (is_editor_preview() asks only then). * * @return bool */ private function in_editing_canvas() { if ( is_preview() ) { return true; } if ( class_exists( '\\Elementor\\Plugin' ) && isset( \Elementor\Plugin::$instance->preview ) && is_callable( array( \Elementor\Plugin::$instance->preview, 'is_preview_mode' ) ) && \Elementor\Plugin::$instance->preview->is_preview_mode() ) { return true; } if ( function_exists( 'bricks_is_builder' ) && bricks_is_builder() ) { return true; } if ( function_exists( 'et_core_is_fb_enabled' ) && et_core_is_fb_enabled() ) { return true; } if ( class_exists( 'FLBuilderModel' ) && is_callable( array( 'FLBuilderModel', 'is_builder_active' ) ) && FLBuilderModel::is_builder_active() ) { return true; } if ( function_exists( 'vc_is_page_editable' ) && vc_is_page_editable() ) { return true; } if ( defined( 'SHOW_CT_BUILDER' ) && SHOW_CT_BUILDER ) { return true; } // Query flags the builders' editor frames carry: Elementor, Bricks, // Etch (its editor is the front-end page itself under ?etch=, with // the canvas in an iframe it fills by script), Divi, Beaver // Builder, Oxygen, Breakdance, WPBakery, Visual Composer, Brizy, // Thrive, Zion, Cornerstone, SiteOrigin. $flags = array( 'elementor-preview', 'bricks', 'etch', 'et_fb', 'fl_builder', 'fl_builder_preview', 'ct_builder', 'ct_inner', 'breakdance', 'breakdance_iframe', 'vc_editable', 'vcv-editable', 'brizy-edit', 'brizy-edit-iframe', 'tve', 'zion_builder_active', 'cornerstone', 'cs_preview', 'siteorigin_panels_live_editor' ); foreach ( $flags as $flag ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only detection of an editor frame. if ( isset( $_GET[ $flag ] ) ) { return true; } } return false; } /** * Whether the current URL is inside a test's page scope, by the same * rules as the runtime's pathMatches() (CONTRACTS targeting.pages): the * pattern's path against the decoded request path, trailing slash * ignored, `*` matching anything; and a pattern with a query part * ("/?page_id=12", which plain permalinks need, where every page's path * is the home path) also names the request's query as a URL test's * address does (request_names()). Depends only on the request's * address, so page caches, which key pages by it, stay correct. * * @param array $test Test with targeting. * @return bool */ private function page_in_scope( $test ) { $pg = isset( $test['targeting']['pages'] ) && is_array( $test['targeting']['pages'] ) ? $test['targeting']['pages'] : array(); if ( empty( $pg['mode'] ) || 'all' === $pg['mode'] || empty( $pg['patterns'] ) || ! is_array( $pg['patterns'] ) ) { return true; } $here = rawurldecode( $this->current_path() ); $hit = false; foreach ( $pg['patterns'] as $p ) { $p = (string) $p; $at = strpos( $p, '?' ); $query = false === $at ? null : (string) substr( $p, $at + 1 ); // A "?" or "#" in the path itself is stored as its escape. $p = str_ireplace( array( '%3F', '%23' ), array( '?', '#' ), false === $at ? $p : substr( $p, 0, $at ) ); $p = strlen( $p ) > 1 ? rtrim( $p, '/' ) : $p; $re = '#^' . str_replace( '\*', '.*', preg_quote( $p, '#' ) ) . '$#u'; if ( preg_match( $re, $here ) && ( null === $query || $this->request_names( $query ) ) ) { $hit = true; break; } } return 'include' === $pg['mode'] ? $hit : ! $hit; } /** * Read a single plugin setting. * * @param string $key Key. * @param mixed $default Fallback. * @return mixed */ private function setting( $key, $default ) { if ( ! class_exists( 'ImproveAB_Settings' ) ) { return $default; } $s = ImproveAB_Settings::get(); return isset( $s[ $key ] ) ? $s[ $key ] : $default; } /** * Enqueue the runtime and localise its configuration (all running tests). */ public function enqueue() { /** * Allow sites to disable the front-end runtime, e.g. to gate it behind * their own consent-management logic. Return false to skip output. * * @param bool $enabled Whether the front-end runtime should load. */ if ( ! apply_filters( 'improveab_enabled', true ) ) { return; } if ( $this->is_editor_preview() ) { return; } $settings = ImproveAB_Settings::get(); if ( ! empty( $settings['exclude_logged_in'] ) && is_user_logged_in() ) { return; } $front = $this->front(); // A site with no tests of its own may still be the far side of a // cross-domain handoff: the visitor arrives with ?abx=, the record is // stored, and the conversion happens pages later (thank-you page). // Only the browser knows a handoff is pending, so once this site has // received one (a page reached through a handoff link) the runtime // loads on every page; before that, only on pages with tests or a // handoff in the address. "A handoff in the address" is one the // runtime would act on (handoff_in_request()), not any text that // holds "abx=": that would let one request from anybody switch the // runtime on for every page and purge every page cache. $has_abx = $this->handoff_in_request(); if ( $has_abx ) { self::handoff_seen(); } /** * Whether to load the runtime on pages with no running test and no * handoff in the URL. Default: whether this site has ever received a * cross-domain handoff (the `improveab_handoff_seen` option). * * @param bool $always Default whether a handoff has arrived here. */ if ( 0 === (int) $front['n'] && ! $front['preview'] && ! $has_abx && ! apply_filters( 'improveab_load_runtime_always', (bool) get_option( 'improveab_handoff_seen' ) ) ) { return; } // Defer: the runtime already waits for DOMContentLoaded (or runs // immediately when the DOM is ready), and the head snippet's inline // failsafe reveals the page even if this file never executes - so a // deferred, non-parser-blocking load changes nothing except letting // the HTML parse without waiting on this download. The array form of // the argument exists since WP 6.3; older versions get the previous // head placement. $script_args = version_compare( get_bloginfo( 'version' ), '6.3', '>=' ) ? array( 'in_footer' => false, 'strategy' => 'defer' ) : false; wp_register_script( 'improveab-runtime', IMPROVEAB_URL . 'assets/js/runtime.js', array(), improveab_asset_ver( 'assets/js/runtime.js' ), $script_args ); $config = array( 'endpoint' => esc_url_raw( rest_url( ImproveAB_REST::NS . '/event' ) ), 'timeout' => self::TIMEOUT_MS, // Hosts a cross-domain handoff (?abx=) may report back to: this // site's own registrable domain, so a redesign on a subdomain // works out of the box, plus any origin the filter names. The // runtime refuses every other endpoint, so a crafted link can // never plant a beacon to an outside host. 'hoHosts' => improveab_trusted_hosts(), // The staff flag lets the runtime decide who takes part at display // time (staff never do), so excluded visitors see the control. // This is the primary guard: event beacons carry no REST nonce, so // they reach the server anonymous and the server-side re-check // only bites when a request is authenticated. 'ed' => current_user_can( 'edit_posts' ) ? 1 : 0, ); $previewing = $this->preview_slug(); // Each test's entry, encoded once per cache generation // (ImproveAB_Tests::front()); a visual test outside its page scope // takes its trimmed entry. $entries = (array) $front['cfg']; if ( $front['preview'] ) { $entries[] = $front['preview']['pieces']['cfg']; } $parts = array(); $exact = false; foreach ( $entries as $e ) { $trim = '' !== $e['t'] && $previewing !== $e['s'] && ! $this->page_in_scope( array( 'targeting' => array( 'pages' => $e['pg'] ) ) ); $parts[] = $trim ? $e['t'] : $e['j']; $exact = $exact || ! empty( $e['q'] ); } // Printed by print_config() as a JSON script, not wp_localize_script // (it casts top-level scalars to strings, and the runtime's staff gate // reads cfg.ed numerically) and not inline JavaScript (a strict CSP // blocks that; JSON is inert data). The tests' entries are already // JSON, so the object is closed around them in the order 1.0.0 // printed: endpoint, timeout, hoHosts, ed, li, tests, purchase, qv. $json = (string) wp_json_encode( $config, JSON_HEX_TAG ); $json = substr( $json, 0, -1 ) . ',"tests":[' . implode( ',', $parts ) . ']'; $purchase = $this->purchase_payload(); if ( null !== $purchase ) { $json .= ',"purchase":' . wp_json_encode( $purchase, JSON_HEX_TAG ); } // The query vars that pick what WordPress shows, as the redirect // script gets them, only while a page-visit goal names one page // exactly or a page rule names a page by its query: a search, or // under plain permalinks another page, on that page's path is not // that page (runtime.js urlGoalHit() and pathMatches()). if ( $exact ) { $json .= ',"qv":' . wp_json_encode( implode( ' ', $this->content_query_vars() ), JSON_HEX_TAG ); } $this->config = $json . '}'; wp_enqueue_script( 'improveab-runtime' ); } /** * Print the runtime configuration as an inert JSON script the runtime * reads with JSON.parse. JSON_HEX_TAG keeps "<" out of the tag body on * top of json_encode's slash escaping, so variant markup can never * close the element. */ public function print_config() { if ( null === $this->config ) { return; } // Escaped here, where it is printed: the fragments were encoded with // JSON_HEX_TAG when the cache was built, and a < or > that reached the // store any other way is written as the same character's \u escape, // which reads back the same and can neither close the tag nor open // a comment. Valid JSON has neither outside a string. wp_print_inline_script_tag( str_replace( array( '<', '>' ), array( '\u003C', '\u003E' ), $this->config ), array( 'type' => 'application/json', 'id' => 'improveab-config' ) ); } /** * Whether this request carries a cross-domain handoff the runtime would * act on: ?abx=, ?abv= and ?abvid= (strings), the instruction decoding to * an object that names a test and an https ImproveAB event route on a * host this site trusts for handoffs (improveab_trusted_hosts(), the * runtime's `hoHosts`) other than this page's own, and not past its * expiry. The same judgement runtime.js makes, except for the signature, * which only the site that minted the instruction can check. It does not * make a crafted link harmless (anyone can build one that reads this * way); it keeps text that merely holds "abx=" from counting, and what a * link makes the page do is the runtime's and the origin's to refuse. * * @return bool */ private function handoff_in_request() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- a read-only look at the address, as the runtime does: the value is decoded and judged below, never printed or stored. if ( ! isset( $_GET['abx'], $_GET['abv'], $_GET['abvid'] ) || ! is_string( $_GET['abx'] ) || ! is_string( $_GET['abv'] ) || ! is_string( $_GET['abvid'] ) || '' === $_GET['abx'] || '' === $_GET['abv'] || '' === $_GET['abvid'] ) { return false; } // A token is base64url, a dot and hex, which the sanitizer leaves as // it is. $token = sanitize_text_field( wp_unslash( $_GET['abx'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( strlen( $token ) > 4096 ) { return false; } $head = explode( '.', $token ); $json = base64_decode( strtr( $head[0], '-_', '+/' ), false ); // Lenient, as the runtime's atob() is. $obj = false === $json ? null : json_decode( $json, true ); if ( ! is_array( $obj ) || empty( $obj['s'] ) || ! is_string( $obj['s'] ) || empty( $obj['e'] ) || ! is_string( $obj['e'] ) ) { return false; } if ( isset( $obj['x'] ) && is_numeric( $obj['x'] ) && (int) $obj['x'] <= time() ) { return false; } if ( ! preg_match( '#^https://[^?\#]*(/improveab/v1/event/?$|[?&]rest_route=/improveab/v1/event$)#i', $obj['e'] ) ) { return false; } // Hosts in the form a browser names them (an international name as // xn--), as the runtime reads them from the URL parser. $host = improveab_ascii_host( (string) wp_parse_url( $obj['e'], PHP_URL_HOST ) ); if ( '' === $host ) { return false; } $own = improveab_ascii_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); if ( preg_replace( '/^www\./', '', $host ) === preg_replace( '/^www\./', '', $own ) ) { return false; } return improveab_host_within( $host, improveab_trusted_hosts() ); } /** * Note that this site has received a cross-domain handoff (CONTRACTS 3, * `improveab_handoff_seen`): from then on the runtime loads on every page, * since the conversion may come pages after the visitor arrived. Written * once; the page caches are asked to let go of pages rendered without * the runtime. */ public static function handoff_seen() { if ( get_option( 'improveab_handoff_seen' ) ) { return; } if ( add_option( 'improveab_handoff_seen', (string) time(), '', true ) && class_exists( 'ImproveAB_Cache' ) ) { ImproveAB_Cache::purge_pages( 'handoff' ); } } /** * Build the purchase payload for the runtime. * * @return array|null */ private function purchase_payload() { // Detect the WooCommerce order-received page during enqueue and read the // order total from the order-received query var. The order key from the // URL must match the order (the same check WooCommerce uses before // showing order details), so totals cannot be read — nor revenue tokens // minted — for arbitrary order ids. Real thank-you URLs always carry it. if ( null === $this->purchase_total && function_exists( 'is_order_received_page' ) && is_order_received_page() ) { $order_id = absint( get_query_var( 'order-received' ) ); if ( $order_id && function_exists( 'wc_get_order' ) ) { $order = wc_get_order( $order_id ); $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public thank-you page; the order key is the credential. // An order counts once it is paid (decision 22). An order // WooCommerce counts as paid has its revenue signed (its total // less refunds); one not paid yet (a bank transfer on hold, a // gateway that confirms after the redirect, a failed or // cancelled payment the shopper can still make through the Pay // link) gets a proof that keeps the visitor's place on the // order, and converts when it is paid. So placing and // abandoning orders converts nothing. An order refunded in full // or in the trash is no purchase at all. if ( $order && '' !== $key && hash_equals( (string) $order->get_order_key(), $key ) && ! $order->has_status( array( 'refunded', 'trash' ) ) ) { $this->purchase_paid = $order->is_paid(); $this->purchase_total = $this->purchase_paid ? max( 0, (float) $order->get_total() - (float) $order->get_total_refunded() ) : 0.0; $this->purchase_order_id = $order_id; } } } if ( null === $this->purchase_total ) { return null; } // The proof names the running tests with the purchase goal: a token // lifted from this page credits none of the site's other tests. // `paid` (additive) tells the runtime whether this page's order // counts now. One still waiting for its payment converts when it is // paid, so its visitor stays free to convert meanwhile; a handoff's // conversion, which no proof can follow, counts a paid order only. $front = $this->front(); $tests = array_map( 'intval', (array) $front['buy'] ); $value = round( $this->purchase_total, 2 ); if ( empty( $tests ) ) { return array( 'value' => $value, 'paid' => $this->purchase_paid, ); } // Short-lived: the conversion beacon fires seconds after the thank-you // page renders, so an hour is generous while making the token useless // for later replay farming. A paid order with nothing to pay (a full // discount) carries the waiting proof too, which credits a paid order // at once. if ( $value <= 0 ) { return array( 'value' => 0, 'sig' => improveab_sign( array( 'o' => (int) $this->purchase_order_id, 't' => $tests, 'x' => time() + HOUR_IN_SECONDS ), 'pend' ), 'paid' => $this->purchase_paid, ); } // Sign the amount so the runtime can prove to the event endpoint that it // came from the server (the order total), not from a tampered client. return array( 'value' => $value, 'sig' => improveab_sign( array( 'v' => $value, 'o' => (int) $this->purchase_order_id, 't' => $tests, 'x' => time() + HOUR_IN_SECONDS ), 'rev' ), 'paid' => true, ); } /** * The element selector of a visual test (stored on every variant; the * first one wins). * * @param array $test Test with variants. * @return string */ private static function visual_selector( $test ) { foreach ( (array) $test['variants'] as $v ) { if ( ! empty( $v['edits'] ) && is_array( $v['edits'] ) && ! empty( $v['edits']['selector'] ) ) { return (string) $v['edits']['selector']; } } return ''; } /** * The full goal catalog for the picker: every goal type the plugin * knows, with its label and whether this site has switched it off. * * @return array */ private function goal_catalog() { $avail = improveab_goal_types(); $all = array( 'click' => __( 'Clicks this element', 'improveab-ab-testing' ), 'url' => __( 'Visits a page', 'improveab-ab-testing' ), 'link_click' => __( 'Clicks a link', 'improveab-ab-testing' ), 'form' => __( 'Submits a form', 'improveab-ab-testing' ), 'add_to_cart' => __( 'Adds a product to the cart (WooCommerce)', 'improveab-ab-testing' ), 'scroll' => __( 'Scrolls the page', 'improveab-ab-testing' ), 'time' => __( 'Stays on the page', 'improveab-ab-testing' ), 'active_time' => __( 'Is active on the page', 'improveab-ab-testing' ), 'purchase' => __( 'Completes a purchase (WooCommerce)', 'improveab-ab-testing' ), ); $out = array(); foreach ( $all as $gid => $glabel ) { $out[] = array( 'id' => $gid, 'label' => $glabel, 'off' => ! in_array( $gid, $avail, true ), ); } return $out; } /** * Admin-bar menu for managers on the front end: start a point-and-click * test on the current page, see or exit an active variant preview, and * jump to any test by its state (Running now, Paused, Drafts, Stopped), * then All tests, New test and Settings. Styled by * assets/css/adminbar.css. * * The menu is one node with its own dropdown: a nav of plain links with * group headings, in a .ab-sub-wrapper of the node's HTML. Child nodes * would each get WordPress's ARIA menu roles (6.5 and later), and the * headings would be read as menu items. As a .menupop the node keeps * WordPress's own hover intent, Enter to open and Esc to close (the * links carry .ab-item for that); the bar's script gives its link the * aria-expanded WordPress then keeps in step (see * enqueue_admin_bar_style()). The hooks picker.js relies on stay: * #wp-admin-bar-improveab-new holds the link that starts picking, each * test is an li #wp-admin-bar-improveab-t{id}, and its type tag is * .improveab-ab-tag, which the picker rewrites to "On this page" after * adding .improveab-ab-onpage to the li. * * @param WP_Admin_Bar $bar Admin bar instance. */ public function admin_bar( $bar ) { // Builder editor frames get neither the menu nor the picker it drives. if ( is_admin() || ! improveab_can_manage() || $this->is_editor_preview() ) { return; } $running = ImproveAB_Tests::running_with_variants(); $count = count( $running ); // Brand mark, label, and a running-count pill. The pill is hidden from // screen readers; the node's title attribute says the same in words. $title = '' . esc_html__( 'A/B Tests', 'improveab-ab-testing' ) . ''; if ( $count > 0 ) { $title .= ''; } $bar->add_node( array( 'id' => 'improveab', 'title' => $title, 'href' => admin_url( 'admin.php?page=' . ImproveAB_Admin::SLUG ), 'meta' => array( 'class' => 'menupop', /* translators: %d: number of running tests. */ 'title' => sprintf( _n( '%d running test', '%d running tests', $count, 'improveab-ab-testing' ), $count ), 'html' => '
' . $this->admin_bar_menu( $running ) . '
', ), ) ); } /** * The A/B Tests menu's own markup. * * @param array $running Running tests with variants. * @return string Escaped HTML. */ private function admin_bar_menu( $running ) { $admin = admin_url( 'admin.php?page=' . ImproveAB_Admin::SLUG ); $html = ''; } /** * One test in the admin-bar menu: its name, cut with an ellipsis before * the tag (the full name is in the link's title), and its type as an * outlined tag, or the red Split off tag when a running or paused * test's traffic split is off. The warning carries no .improveab-ab-tag, * so the picker's "On this page" never writes over it. A type is named * as New test and the results page name it (admin.js typeLabel()). * * @param array $test Test row (a running test with variants, or id, * name, type and status). * @param string $admin The tests list's address. * @return string Escaped HTML. */ private function admin_bar_row( $test, $admin ) { $types = array( 'element' => __( 'Element / section', 'improveab-ab-testing' ), 'visual' => __( 'Point-and-click', 'improveab-ab-testing' ), 'url' => __( 'Full page / URL', 'improveab-ab-testing' ), ); if ( in_array( $test['status'], array( 'running', 'paused' ), true ) && ImproveAB_Stats::split_off( $test ) ) { $tag = '' . esc_html__( 'Split off', 'improveab-ab-testing' ) . ''; } else { $tag = '' . esc_html( isset( $types[ $test['type'] ] ) ? $types[ $test['type'] ] : $types['element'] ) . ''; } return '
  • ' . '' . esc_html( $test['name'] ) . '' . $tag . '
  • '; } /** * The admin-bar menu's stylesheet, wherever the menu is drawn (the front * end, for a user who can manage tests, outside a builder's editor frame; * admin_bar() adds nothing in wp-admin). The menu keeps WordPress's * default dark bar there: admin colour schemes only load in wp-admin. */ public function enqueue_admin_bar_style() { if ( is_admin() || ! is_admin_bar_showing() || ! improveab_can_manage() || $this->is_editor_preview() ) { return; } wp_enqueue_style( 'improveab-adminbar', IMPROVEAB_URL . 'assets/css/adminbar.css', array( 'admin-bar' ), improveab_asset_ver( 'assets/css/adminbar.css' ) ); // The menu's link says whether the menu is open, kept in step with the // item's "hover" class here: this menu is the node's own HTML, and // the admin-bar.js of older WordPress (6.0, the oldest supported) // never updates aria-expanded, so an open menu was announced closed. And WordPress opens every menu of the // bar's left group from its item's left edge: on a busy bar (a // builder's items, a second row) A/B Tests can sit far enough right // that the 440 px menu would run off the window, so as it opens it is // aligned to the item's right edge instead (.abw-end). The bar may be // printed after the footer scripts, hence the wait. wp_add_inline_script( 'admin-bar', 'document.addEventListener("DOMContentLoaded",function(){var li=document.getElementById("wp-admin-bar-improveab"),a=li&&li.querySelector(".ab-item");if(!a){return;}function sync(){a.setAttribute("aria-expanded",/(^|\\s)hover(\\s|$)/.test(li.className)?"true":"false");}sync();if(window.MutationObserver){new MutationObserver(sync).observe(li,{attributes:true,attributeFilter:["class"]});}function place(){var r=li.getBoundingClientRect(),w=Math.min(440,window.innerWidth-16);li.classList.toggle("abw-end",r.left+w>window.innerWidth-8);}li.addEventListener("mouseenter",place);li.addEventListener("focusin",place);});' ); } /** * The point-and-click panel for managers. Independent of the visitor * runtime (which may be skipped entirely, e.g. when logged-in users are * excluded), so admins can always start a test. A manager's page view * carries the panel's settings (`ImproveABPicker`) and a small loader * (assets/js/picker-loader.js) that marks the tests on the page in the * A/B Tests menu, but not the panel itself. Start a test on this page * links to the same page with ?improveab-picker=: that page * carries the panel and the Media Library, enqueued here like any other * script, and the loader starts picking on it. */ public function enqueue_picker() { if ( ! is_admin_bar_showing() || ! improveab_can_manage() || $this->is_editor_preview() ) { return; } $manifest = $this->picker_manifest(); $open = isset( $_GET['improveab-picker'] ) && is_string( $_GET['improveab-picker'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['improveab-picker'] ) ), 'improveab_picker' ); wp_enqueue_script( 'improveab-picker-loader', IMPROVEAB_URL . 'assets/js/picker-loader.js', array(), improveab_asset_ver( 'assets/js/picker-loader.js' ), true ); if ( $open ) { $this->register_picker( $manifest ); wp_enqueue_style( 'improveab-picker' ); wp_enqueue_script( 'improveab-picker' ); // The Media Library, so an image field can be swapped from it. if ( function_exists( 'wp_enqueue_media' ) ) { wp_enqueue_media(); } } else { // The settings ride with the loader, which reads the tests in // them to mark the ones on this page. $this->register_picker( $manifest, 'improveab-picker-loader' ); } wp_add_inline_script( 'improveab-picker-loader', 'window.ImproveABLoader = ' . wp_json_encode( array( 'onThisPage' => __( 'On this page', 'improveab-ab-testing' ), 'open' => $open, 'nonce' => wp_create_nonce( 'improveab_picker' ), ), JSON_HEX_TAG ) . ';', 'before' ); } /** * How many tests are in each status, counted once for this page view. * * @return array Map of status => number of tests. */ private function status_counts() { if ( null === $this->status_counts ) { $this->status_counts = ImproveAB_Tests::count_by_status(); } return $this->status_counts; } /** * The admin-only list of running tests the panel and the A/B Tests menu * mark as on this page. * * @return array */ private function picker_manifest() { // A minimal manifest of running tests so the picker can mark the ones // present on this page in the admin-bar list. Admin-only payload. $manifest = array(); foreach ( ImproveAB_Tests::running_with_variants() as $test ) { $item = array( 'id' => (int) $test['id'], 'slug' => $test['slug'], 'name' => $test['name'], 'type' => $test['type'], ); if ( 'url' === $test['type'] ) { foreach ( $test['variants'] as $v ) { if ( 'default' === $v['variant_key'] ) { $item['entry'] = (string) wp_parse_url( (string) $v['url'], PHP_URL_PATH ); $item['entryq'] = (string) wp_parse_url( (string) $v['url'], PHP_URL_QUERY ); } } } elseif ( 'visual' === $test['type'] ) { $item['selector'] = self::visual_selector( $test ); } else { $item['classes'] = array_values( array_filter( wp_list_pluck( $test['variants'], 'class_name' ) ) ); } // A site-wide element whose test is kept off this page by its // page rule (a header banner tested on /blog/* only) is on the // page but does not run here: the menu must not call it "On this // page". if ( 'url' !== $test['type'] && ! $this->page_in_scope( $test ) ) { $item['away'] = true; } $manifest[] = $item; } return $manifest; } /** * The paused element and visual tests the panel refuses picks on, each * marked `away` where its page rule keeps it off this page, as * picker_manifest() marks the running ones; the rule itself stays here. * * @return array */ private function paused_manifest() { $out = array(); foreach ( ImproveAB_Tests::paused_elements() as $item ) { $pages = isset( $item['pages'] ) && is_array( $item['pages'] ) ? $item['pages'] : array(); unset( $item['pages'] ); if ( ! $this->page_in_scope( array( 'targeting' => array( 'pages' => $pages ) ) ) ) { $item['away'] = true; } $out[] = $item; } return $out; } /** * Register the panel's script and stylesheet, and put the panel's * settings on $handle: the panel's own script when it loads with the * page, else the loader that brings it. * * @param array $manifest picker_manifest(). * @param string $handle Script the settings are printed before. */ private function register_picker( $manifest, $handle = 'improveab-picker' ) { wp_register_style( 'improveab-picker', IMPROVEAB_URL . 'assets/css/picker.css', array(), improveab_asset_ver( 'assets/css/picker.css' ) ); // wp-i18n gives the panel's counts their language's own plural forms // (_n); every other string comes localized in ImproveABPicker.i18n. wp_register_script( 'improveab-picker', IMPROVEAB_URL . 'assets/js/picker.js', array( 'wp-i18n' ), improveab_asset_ver( 'assets/js/picker.js' ), true ); wp_set_script_translations( 'improveab-picker', 'improveab-ab-testing' ); // The menu counts the tests by status on every manager's page view; // most sites have nothing paused, and then the panel's paused list // costs no query of its own. $counts = $this->status_counts(); wp_localize_script( $handle, 'ImproveABPicker', array( 'root' => esc_url_raw( rest_url( ImproveAB_REST::NS . '/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), // For the preview links the panel builds after a save // (improveab_preview_request()). 'previewNonce' => wp_create_nonce( 'improveab_preview' ), 'adminUrl' => esc_url_raw( admin_url( 'admin.php?page=' . ImproveAB_Admin::SLUG ) ), 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=' . ImproveAB_Admin::SLUG . '#/settings' ) ), 'maxVariants' => improveab_max_variants(), // The same goal catalog as the dashboard wizard. Every goal is // offered; one a site turned off through // improveab_goal_types is shown disabled, and the server // refuses it too. 'goals' => $this->goal_catalog(), 'tests' => $manifest, // Paused element tests by the classes on their versions and // paused visual tests by their selector: an element carrying, // inside or around one is refused as a running test's is, // since the test takes its visitors back when it is resumed. // Kept out of `tests`, whose items the menu marks On this // page. 'paused' => empty( $counts['paused'] ) ? array() : $this->paused_manifest(), // The query vars that make a request another page than a // test's entry URL that does not name them (the On this page // mark follows the redirect script's rule). 'qv' => $this->content_query_vars(), // The site's home path ("/", or "/shop/" for a site in /shop/): // a goal page on it is named by its query, and "Path contains" // it would match every page. 'homePath' => (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), // Plain permalinks: every page's path is the home path, so the // Pages default names the page by its query and a rule on the // home path alone is every page. 'plainLinks' => '' === (string) get_option( 'permalink_structure' ), 'i18n' => array( 'needText' => __( 'That element has no text to test. Pick a headline, a button, or another text element.', 'improveab-ab-testing' ), 'needTextImage' => __( 'A picture has no text to test. To swap it, pick a part around it that also holds words, such as its section: the picture is a field there, with Choose.', 'improveab-ab-testing' ), 'panelTitle' => __( 'New test', 'improveab-ab-testing' ), 'selectedEl' => __( 'Selected element', 'improveab-ab-testing' ), 'nameLabel' => __( 'Test name', 'improveab-ab-testing' ), /* translators: %s: the variant's letter, B to F. Used as the variant's name. */ 'variantName' => __( 'Variant %s', 'improveab-ab-testing' ), 'goalLabel' => __( 'Conversion goal', 'improveab-ab-testing' ), 'proGoals' => __( 'Greyed goals are switched off on this site.', 'improveab-ab-testing' ), 'goalPage' => __( 'Goal page path', 'improveab-ab-testing' ), 'pageSearchTip' => __( 'Start typing to search your pages, or type any path by hand.', 'improveab-ab-testing' ), 'matchContains' => __( 'Path contains', 'improveab-ab-testing' ), 'matchExact' => __( 'Path is exactly', 'improveab-ab-testing' ), 'urlTip' => __( 'Path contains also matches dynamic endings, so /thank-you/ also counts /thank-you/order-123/. Path is exactly counts only that precise path. A query you add, such as ?page_id=12 under plain permalinks, has to be in the address as well.', 'improveab-ab-testing' ), 'clickWatch' => __( 'CSS selector to watch', 'improveab-ab-testing' ), 'clickTip' => __( 'Counts a click on any element matching this selector. Pre-filled with the element you picked; edit it, or pick the exact element on the page.', 'improveab-ab-testing' ), // The field's "optional" mark sits beside these labels. 'linkContains' => __( 'Link URL contains', 'improveab-ab-testing' ), 'linkTip' => __( 'Leave blank to count any link, or enter text the link URL must contain.', 'improveab-ab-testing' ), 'formSel' => __( 'Form selector', 'improveab-ab-testing' ), 'formTip' => __( 'Counts a submission of any form on the page, or only forms matching this selector. Works with Contact Form 7, WPForms, Gravity Forms, Fluent Forms, Ninja Forms, Formidable and plain HTML forms.', 'improveab-ab-testing' ), 'cartProduct' => __( 'Product ID', 'improveab-ab-testing' ), 'cartTip' => __( 'Counts when a visitor adds a product to the WooCommerce cart. Leave blank for any product, or enter one product ID.', 'improveab-ab-testing' ), 'scrollDepth' => __( 'Scroll depth (%)', 'improveab-ab-testing' ), 'secondsOnPage' => __( 'Seconds on page', 'improveab-ab-testing' ), 'timeTip' => __( 'Total time on the page, whether or not the visitor is active.', 'improveab-ab-testing' ), 'activeTip' => __( 'Only counts time the visitor is actually engaged: the tab is visible and they interacted recently.', 'improveab-ab-testing' ), 'purchaseNote' => __( 'Counts paid WooCommerce orders. An order waiting for payment counts when it is paid; refunds and cancellations are taken off its revenue. Requires WooCommerce.', 'improveab-ab-testing' ), 'saveDraft' => __( 'Save draft', 'improveab-ab-testing' ), 'saveStart' => __( 'Save and start', 'improveab-ab-testing' ), 'saving' => __( 'Saving…', 'improveab-ab-testing' ), 'cancel' => __( 'Cancel', 'improveab-ab-testing' ), 'createdDraft' => __( 'Draft saved. Start it any time from the tests list.', 'improveab-ab-testing' ), 'createdRunning' => __( 'Your test is running.', 'improveab-ab-testing' ), 'viewResults' => __( 'View results', 'improveab-ab-testing' ), 'done' => __( 'Done', 'improveab-ab-testing' ), 'onThisPage' => __( 'On this page', 'improveab-ab-testing' ), 'genericError' => __( 'Something went wrong. Please try again.', 'improveab-ab-testing' ), 'offline' => __( 'Could not reach your site. Check your connection and try again; nothing in this panel is lost.', 'improveab-ab-testing' ), /* translators: %d: sequence number appended to a default test name, e.g. "Pricing - Test 2". */ 'nameSuffixN' => __( 'Test %d', 'improveab-ab-testing' ), 'original' => __( 'Original', 'improveab-ab-testing' ), 'parentSel' => __( 'Select parent', 'improveab-ab-testing' ), 'advSplit' => __( 'Traffic split', 'improveab-ab-testing' ), /* translators: %s: name of the running test. */ 'alreadyTested' => __( 'This element is already part of the running test “%s”. Stop that test first, or pick a different element.', 'improveab-ab-testing' ), /* translators: %s: name of the paused test. */ 'alreadyPaused' => __( 'This element is already part of the paused test “%s”. Stop that test first, or pick a different element.', 'improveab-ab-testing' ), // Select parent refused: the part around the picked one holds the test's element. /* translators: %s: name of the running test. */ 'parentTested' => __( 'The part around this one holds an element of the running test “%s”. Stop that test first, or keep the part you picked.', 'improveab-ab-testing' ), /* translators: %s: name of the paused test. */ 'parentPaused' => __( 'The part around this one holds an element of the paused test “%s”. Stop that test first, or keep the part you picked.', 'improveab-ab-testing' ), 'scopeField' => __( 'This field', 'improveab-ab-testing' ), 'changedMark' => __( 'Changed', 'improveab-ab-testing' ), 'changedField' => __( 'Changed. Click to put the original back.', 'improveab-ab-testing' ), 'parentNoText' => __( 'The parent has no text to test.', 'improveab-ab-testing' ), 'chooseImage' => __( 'Choose an image from the Media Library', 'improveab-ab-testing' ), 'chooseImageBtn' => __( 'Choose', 'improveab-ab-testing' ), 'useImage' => __( 'Use this image', 'improveab-ab-testing' ), 'pcHeading' => __( 'Heading', 'improveab-ab-testing' ), 'pcParagraph' => __( 'Paragraph', 'improveab-ab-testing' ), 'pcButton' => __( 'Button', 'improveab-ab-testing' ), 'pcCart' => __( 'Cart button', 'improveab-ab-testing' ), 'pcLink' => __( 'Link', 'improveab-ab-testing' ), 'pcItem' => __( 'List item', 'improveab-ab-testing' ), 'pcImage' => __( 'Image', 'improveab-ab-testing' ), 'pcText' => __( 'Text', 'improveab-ab-testing' ), // Said after a field's name, as in "Link, site title". 'pcSiteTitle' => __( 'site title', 'improveab-ab-testing' ), /* translators: Whether the names of page parts above (Heading, Button) are written in lowercase inside a sentence, as in "Undone: heading text". Translate as "no" for a language that capitalizes nouns, such as German; keep "yes" otherwise. */ 'lowerNames' => _x( 'yes', 'lowercase part names in a sentence: yes or no', 'improveab-ab-testing' ), /* translators: %1$s: a field's kind, e.g. Button; %2$s: its number among the fields of that kind. */ 'labelN' => _x( '%1$s %2$s', 'a field kind and its number, e.g. Button 2', 'improveab-ab-testing' ), /* translators: %1$s: a field's name, e.g. Link; %2$s: what the link is, e.g. site title. */ 'labelQual' => _x( '%1$s, %2$s', 'a field and what it is', 'improveab-ab-testing' ), /* translators: %s: variant letter, e.g. B. */ 'removeVariant' => __( 'Remove variant %s', 'improveab-ab-testing' ), /* translators: %s: variant letter, e.g. C. */ 'variantRemoved' => __( 'Variant %s removed', 'improveab-ab-testing' ), /* translators: %s: variant letter, e.g. C. */ 'variantBack' => __( 'Variant %s is back', 'improveab-ab-testing' ), 'unsavedWarn' => __( 'This test is not saved yet and will be lost. Continue?', 'improveab-ab-testing' ), 'pagesLabel' => __( 'Pages', 'improveab-ab-testing' ), 'pagesWhere' => __( 'Where the test runs', 'improveab-ab-testing' ), 'pagesAll' => __( 'Everywhere the element exists', 'improveab-ab-testing' ), 'pagesInclude' => __( 'Only these pages', 'improveab-ab-testing' ), 'pagesExclude' => __( 'Everywhere except these pages', 'improveab-ab-testing' ), /* translators: %s: the name of the page option that lists paths, e.g. Only these pages. */ 'pagesAllHelp' => __( 'Pick “%s” to list paths.', 'improveab-ab-testing' ), /* translators: %s: the name of the page option for every page, Everywhere the element exists. */ 'pagesHere' => __( 'This page, where you picked it. For a part every page shares, such as a header button, choose “%s”.', 'improveab-ab-testing' ), /* translators: 1: a test's name, 2: the name of the page option that lists paths, Only these pages, 3: this page's path. */ 'pagesAway' => __( 'The test “%1$s” changes this element on other pages, so this one can only run here: choose “%2$s” with %3$s.', 'improveab-ab-testing' ), 'nameDupe' => __( 'A running test already uses this name. Pick another so results stay easy to tell apart.', 'improveab-ab-testing' ), /* translators: %s: number of elements matching the generated selector. */ 'selectorDupe' => __( '%s elements on this page match this selector, so visitors may get a different one than the one you picked. Give it a unique id or class in your builder, or pick another element.', 'improveab-ab-testing' ), 'scriptWords' => __( 'A script on this page puts the original words back after it loads (WooCommerce’s product buttons and mini cart do), so visitors would not see your change. To test wording, pick a heading, a paragraph or a plain link.', 'improveab-ab-testing' ), 'servedChanged' => __( 'A script on this page changes this part after it loads (sliders, counters and animated text do), so visitors may not see your version, or see it broken. You can still start the test. If your version does not show as expected, pick another element.', 'improveab-ab-testing' ), 'servedMissing' => __( 'Visitors do not get this element as you see it here when the page loads: a script adds it later (sliders and menus often do), or only logged-in users see it. They may see the original first, or miss the test. You can still start it. If your version does not show as expected, pick another element.', 'improveab-ab-testing' ), 'modeLoseTags' => __( 'Switching to plain text drops the inline formatting from your edited variants. Continue?', 'improveab-ab-testing' ), 'advanced' => __( 'Advanced', 'improveab-ab-testing' ), 'afterSaveFree' => __( 'Notes can be added after saving, on the test screen.', 'improveab-ab-testing' ), 'allPages' => __( 'all pages', 'improveab-ab-testing' ), 'thisPage' => __( 'this page', 'improveab-ab-testing' ), 'anyForm' => __( 'any form', 'improveab-ab-testing' ), 'anyLink' => __( 'any link', 'improveab-ab-testing' ), 'anyProduct' => __( 'any product', 'improveab-ab-testing' ), // Who takes part, said in a line of its own where it cannot // be chosen. 'audFreeLine' => __( 'Everyone except staff takes part: editors and admins never see a variant.', 'improveab-ab-testing' ), 'copied' => __( 'Copied', 'improveab-ab-testing' ), 'copy' => __( 'Copy', 'improveab-ab-testing' ), 'editHtmlShort' => __( 'Edit HTML', 'improveab-ab-testing' ), 'goalChooseShort' => __( 'Choose a goal', 'improveab-ab-testing' ), 'goalPickEl' => __( 'Pick the element', 'improveab-ab-testing' ), /* translators: %s: the goal name. */ 'goalSuggestButton' => __( 'This element is a button, so “%s” is the usual choice.', 'improveab-ab-testing' ), /* translators: %s: the goal name. */ 'goalSuggestLink' => __( 'This is a link, so “%s” is the usual choice.', 'improveab-ab-testing' ), 'goalSuggestPage' => __( 'Most tests count a visit to a thank-you page.', 'improveab-ab-testing' ), 'matchLabel' => __( 'Count it when', 'improveab-ab-testing' ), 'optional' => __( 'optional', 'improveab-ab-testing' ), 'pagesHint' => __( 'One path per line, * allowed', 'improveab-ab-testing' ), /* translators: %s: an example of a page's address under plain permalinks, e.g. /?page_id=12. */ 'pagesHintPlain' => __( 'One per line, * allowed. This site uses plain permalinks, so a page is named by its query, for example %s.', 'improveab-ab-testing' ), /* translators: %s: the page path. */ 'pathExactly' => __( '%s exactly', 'improveab-ab-testing' ), 'pickAnother' => __( 'Pick another', 'improveab-ab-testing' ), 'previewList' => __( 'Preview on page', 'improveab-ab-testing' ), /* translators: %s: the product id. */ 'productN' => __( 'product %s', 'improveab-ab-testing' ), 'required' => __( 'Required', 'improveab-ab-testing' ), /* translators: %d: number of seconds. */ 'secondsShort' => __( '%d s', 'improveab-ab-testing' ), 'selTrunc' => __( 'Truncated. Copy to see the full selector.', 'improveab-ab-testing' ), 'splitEven2' => __( 'Traffic splits evenly, 50 / 50.', 'improveab-ab-testing' ), /* translators: %d: number of variants. */ 'splitEvenN' => __( 'Traffic splits evenly, %d ways.', 'improveab-ab-testing' ), /* translators: %s: the element tag. */ 'thisElement' => __( 'this %s', 'improveab-ab-testing' ), 'variantsLabel' => __( 'Variants', 'improveab-ab-testing' ), 'stripGoalTitle' => __( 'Pick the element visitors click', 'improveab-ab-testing' ), /* translators: %s: the plain name of the picked block, e.g. section. */ 'stripSmallerTitle' => __( 'Pick a smaller part inside the %s', 'improveab-ab-testing' ), 'stripTouchTitle' => __( 'Tap what you want to test', 'improveab-ab-testing' ), 'stripTitle' => __( 'Pick an element to test', 'improveab-ab-testing' ), 'stripTouchHint' => __( 'Tap once to see it, again to use it', 'improveab-ab-testing' ), 'stripClick' => __( 'Click it to open the panel.', 'improveab-ab-testing' ), /* translators: %s: the Up arrow key, drawn as a key cap. */ 'stripUp' => __( '%s takes the part around it.', 'improveab-ab-testing' ), /* translators: %s: the Esc key, drawn as a key cap. */ 'stripEsc' => __( '%s cancels.', 'improveab-ab-testing' ), 'stripClickPart' => __( 'Click it on the page.', 'improveab-ab-testing' ), // Picking from the keyboard: the arrows, then Enter. /* translators: %1$s: the Left arrow key, %2$s: the Right arrow key, both drawn as key caps. %3$s and %4$s wrap the words a narrow screen leaves out (screen readers still read them): what is left must still be a sentence. If your language cannot shorten it, put %3$s%4$s together at the end. */ 'stripMove' => __( '%1$s %2$s move%3$s from part to part%4$s.', 'improveab-ab-testing' ), /* translators: %s: the Enter key, drawn as a key cap. */ 'stripEnter' => __( '%s uses it.', 'improveab-ab-testing' ), /* translators: The Esc key's name as keyboards print it in this language. */ 'keyEsc' => _x( 'Esc', 'keyboard key', 'improveab-ab-testing' ), /* translators: The Enter key's name as keyboards print it in this language. */ 'keyEnter' => _x( 'Enter', 'keyboard key', 'improveab-ab-testing' ), /* translators: %1$s: what a part of the page is, e.g. Heading; %2$s: its first words. Read out while picking with the keyboard. */ 'litSay' => _x( '%1$s: %2$s', 'a part of the page and its words', 'improveab-ab-testing' ), /* translators: %s: the plain name of the picked block, e.g. section. */ 'smallerHint' => __( 'Click a part inside the %s: a heading, a paragraph or one button.', 'improveab-ab-testing' ), /* translators: %1$s: width in CSS pixels, %2$s: height in CSS pixels. */ 'sizeFmt' => __( '%1$s × %2$s', 'improveab-ab-testing' ), /* translators: %1$s: the image's size, e.g. 1200 × 800; %2$s: whether it is the original image, e.g. "replaces the original". */ 'imgMeta' => _x( '%1$s · %2$s', 'an image size and whether it is the original', 'improveab-ab-testing' ), 'imgReplacesOrig' => __( 'replaces the original', 'improveab-ab-testing' ), 'imgSame' => __( 'same as the original', 'improveab-ab-testing' ), 'tapUse' => __( 'Use this', 'improveab-ab-testing' ), 'restoreTip' => __( 'Put the original back', 'improveab-ab-testing' ), /* translators: %1$s: a comma-separated list, %2$s: the last item. */ 'listAnd' => __( '%1$s and %2$s', 'improveab-ab-testing' ), 'origWhat' => __( 'What visitors see today', 'improveab-ab-testing' ), 'origNote' => __( 'The original is never edited here. Pick B, or add a variant, to try new wording.', 'improveab-ab-testing' ), /* translators: %s: the variant name, e.g. Variant B. */ 'variantText' => _x( '%s text', 'the name of a variant\'s text field, e.g. Variant B text', 'improveab-ab-testing' ), /* translators: %s: the variant name, e.g. Variant B. */ 'variantHtml' => __( '%s, HTML', 'improveab-ab-testing' ), 'tabOrigShort' => __( 'A, Original', 'improveab-ab-testing' ), /* translators: %s: the variant letter, e.g. B. */ 'tabChanged' => __( '%s, changed', 'improveab-ab-testing' ), 'addVariantBtn' => __( 'Add variant', 'improveab-ab-testing' ), /* translators: %s: the letter of the variant that will be added, e.g. C. */ 'addVariantTip' => __( 'Adds %s, a copy of the original', 'improveab-ab-testing' ), /* translators: %d: the most variants a test can have. */ 'variantsMax' => __( 'Up to %d variants, all shown on one row.', 'improveab-ab-testing' ), /* translators: %1$s: how many variants the test has, %2$s: the most it can have. */ 'variantsCount' => __( '%1$s of %2$s', 'improveab-ab-testing' ), 'panelPos' => __( 'Panel position', 'improveab-ab-testing' ), 'closePanel' => __( 'Close panel', 'improveab-ab-testing' ), 'posRight' => __( 'Docked on the right', 'improveab-ab-testing' ), 'posLeft' => __( 'Docked on the left', 'improveab-ab-testing' ), 'posFloat' => __( 'Floating over the page', 'improveab-ab-testing' ), 'dockedFor' => __( 'Docked for this element: it is wider than the space beside the panel.', 'improveab-ab-testing' ), /* translators: %s: the plain name of the element around the picked one, e.g. Section. */ 'parentTip' => __( 'Select the %s around it', 'improveab-ab-testing' ), 'whyName' => __( 'Give the test a name to start', 'improveab-ab-testing' ), 'whyGoal' => __( 'Choose a goal to start', 'improveab-ab-testing' ), 'whyPath' => __( 'Add a page path to start', 'improveab-ab-testing' ), 'whyEveryPage' => __( 'Pick one goal page to start', 'improveab-ab-testing' ), /* translators: %s: the variant's name, e.g. Variant B. */ 'whyHtml' => __( 'Fix the HTML in %s to start', 'improveab-ab-testing' ), /* translators: %s: the variant's name, e.g. Variant B. */ 'whySize' => __( '%s is too big: pick a smaller part to start', 'improveab-ab-testing' ), 'whyBackSize' => __( 'Goes back to the variant that is too big', 'improveab-ab-testing' ), 'whyOpenSize' => __( 'Opens the sheet at the variant that is too big', 'improveab-ab-testing' ), // The reason above Save and start is a button: it takes you to // what is missing (on a phone, it opens the sheet there first). 'whyBackName' => __( 'Goes back to the test name', 'improveab-ab-testing' ), 'whyBackGoal' => __( 'Goes back to the goal', 'improveab-ab-testing' ), 'whyBackPath' => __( 'Goes back to the page path', 'improveab-ab-testing' ), 'whyBackHtml' => __( 'Goes back to the HTML to fix', 'improveab-ab-testing' ), 'whyOpenName' => __( 'Opens the sheet at the test name', 'improveab-ab-testing' ), 'whyOpenGoal' => __( 'Opens the sheet at the goal', 'improveab-ab-testing' ), 'whyOpenPath' => __( 'Opens the sheet at the page path', 'improveab-ab-testing' ), 'whyOpenHtml' => __( 'Opens the sheet at the HTML to fix', 'improveab-ab-testing' ), /* translators: %s: the names of the sections below, e.g. Goal, Advanced. */ 'moreBelow' => __( 'More below: %s', 'improveab-ab-testing' ), 'peekMore' => __( 'More', 'improveab-ab-testing' ), 'grabPeekLabel' => __( 'Sheet height: peek. Up arrow for half.', 'improveab-ab-testing' ), 'grabHalfLabel' => __( 'Sheet height: half. Up arrow for full, down arrow for peek.', 'improveab-ab-testing' ), 'grabFullLabel' => __( 'Sheet height: full. Down arrow for half.', 'improveab-ab-testing' ), 'noPathYet' => __( 'no page path yet', 'improveab-ab-testing' ), 'secGoal' => __( 'Goal', 'improveab-ab-testing' ), 'pagesPaths' => __( 'Paths, one per line', 'improveab-ab-testing' ), // Undo and Redo in the variant card, and what they announce. 'undo' => __( 'Undo', 'improveab-ab-testing' ), 'redo' => __( 'Redo', 'improveab-ab-testing' ), // Key names in the Undo and Redo tooltips (Mac shows symbols). 'keyCtrl' => __( 'Ctrl', 'improveab-ab-testing' ), 'keyShift' => __( 'Shift', 'improveab-ab-testing' ), /* translators: %s: what the step changed, e.g. heading text. */ 'undoneStep' => __( 'Undone: %s', 'improveab-ab-testing' ), /* translators: %s: what the step changed, e.g. heading text. */ 'redoneStep' => __( 'Redone: %s', 'improveab-ab-testing' ), // What a step changed, said after "Undone:" and "Redone:". /* translators: %s: the field's label, e.g. heading. */ 'whatFieldText' => _x( '%s text', 'what an undo step changed, e.g. Heading text', 'improveab-ab-testing' ), /* translators: %s: the field's label, e.g. heading. */ 'whatFieldHtml' => __( '%s HTML', 'improveab-ab-testing' ), /* translators: %s: the field's label, e.g. heading. */ 'whatRestore' => __( 'the original %s', 'improveab-ab-testing' ), 'whatText' => __( 'text', 'improveab-ab-testing' ), 'whatHtml' => __( 'HTML', 'improveab-ab-testing' ), // A field's own HTML view (the braces button) and the HTML check. /* translators: %s: the field's label, e.g. heading. */ 'fieldHtmlName' => __( 'Edit the %s\'s HTML', 'improveab-ab-testing' ), 'fieldHtmlTip' => __( 'Edit this field\'s HTML', 'improveab-ab-testing' ), 'htmlAside' => __( 'HTML', 'improveab-ab-testing' ), /* translators: %1$s: the plain name of the picked element, e.g. heading; %2$s: its HTML tag, e.g. h2. */ 'htmlHint' => __( 'Change the words, keep the tags. The %1$s keeps its own %2$s tag.', 'improveab-ab-testing' ), /* translators: %1$s: an opening tag, e.g. ; %2$s: the closing tag it needs, e.g. . */ 'htmlUnclosed' => __( '%1$s is not closed. Add %2$s after the words it covers, or undo.', 'improveab-ab-testing' ), /* translators: %1$s: a closing tag, e.g. ; %2$s: the opening tag it needs, e.g. . */ 'htmlStray' => __( '%1$s has nothing to close. Remove it, or add %2$s before the words it covers.', 'improveab-ab-testing' ), /* translators: %1$s: the start of a tag, e.g. . */ 'htmlUnfinished' => __( '%1$s is not finished. Add %2$s to close it, or undo.', 'improveab-ab-testing' ), /* translators: %s: the characters that end an HTML comment, -->. */ 'htmlComment' => __( 'A comment is not closed. Add %s at its end, or undo.', 'improveab-ab-testing' ), /* translators: %s: an opening tag, e.g.

    . */ 'htmlReshaped' => __( 'The browser reads %s differently from how it is written here. Check that tag, or undo.', 'improveab-ab-testing' ), // A big block: a neutral note with a way to a smaller part. /* translators: %1$s: "This block holds 14 elements"; %2$s: "6 with text you can edit". */ 'bigLead' => __( '%1$s, and %2$s.', 'improveab-ab-testing' ), /* translators: %1$s: "This block holds 14 elements"; %2$s: "6 with text you can edit". */ 'bigLeadLinks' => __( '%1$s, links and buttons among them, and %2$s.', 'improveab-ab-testing' ), 'bigRisk' => __( 'Changing them all at once can break a menu, a form or add to cart. A heading, a paragraph or one button is safer to test.', 'improveab-ab-testing' ), 'bigKept' => __( 'A big block: preview each variant on the page before you start.', 'improveab-ab-testing' ), 'pickSmallerPart' => __( 'Pick a smaller part', 'improveab-ab-testing' ), 'keepBlock' => __( 'Keep this block', 'improveab-ab-testing' ), /* translators: %1$s: "Show 4 more fields"; %2$s: "1 changed". */ 'moreFieldsJoin' => _x( '%1$s, %2$s', 'more fields and how many changed', 'improveab-ab-testing' ), 'fewerFields' => __( 'Show fewer fields', 'improveab-ab-testing' ), 'errName' => __( 'Give the test a name.', 'improveab-ab-testing' ), /* translators: %s: the variant letter, e.g. C. */ 'errVariant' => __( 'Variant %s is empty. Write its text, or remove it.', 'improveab-ab-testing' ), // The server keeps the first 20,000 characters of a variant's // HTML, so a bigger one is refused before it is cut. /* translators: %1$s: a variant's name, e.g. Variant B; %2$s: how many characters its HTML has; %3$s: the most a variant holds (20,000). */ 'errSize' => __( '%1$s is too big to save: its HTML has %2$s characters, and a variant holds %3$s. Pick a smaller part of the page.', 'improveab-ab-testing' ), 'errGoal' => __( 'Choose a goal to start the test.', 'improveab-ab-testing' ), 'errPath' => __( 'Add a page path.', 'improveab-ab-testing' ), /* translators: %1$s: the site's home path, e.g. /. %2$s: an example of a page's address there, e.g. /?page_id=12. */ 'errPagesEvery' => __( 'Every page on this site has the path %1$s, so this line matches every page. Add the page’s query, for example %2$s.', 'improveab-ab-testing' ), 'errEveryPage' => __( 'This matches every page, so every visitor would count as soon as they arrive. Pick the goal page from the list, or choose Path is exactly.', 'improveab-ab-testing' ), 'starting' => __( 'Starting…', 'improveab-ab-testing' ), 'nmImageBox' => __( 'Image box', 'improveab-ab-testing' ), 'nmIconBox' => __( 'Icon box', 'improveab-ab-testing' ), 'nmCta' => __( 'Call to action', 'improveab-ab-testing' ), 'nmList' => __( 'List', 'improveab-ab-testing' ), 'nmForm' => __( 'Form', 'improveab-ab-testing' ), 'nmField' => __( 'Field', 'improveab-ab-testing' ), 'nmMenu' => __( 'Menu', 'improveab-ab-testing' ), 'nmTestimonial' => __( 'Testimonial', 'improveab-ab-testing' ), 'nmPriceTable' => __( 'Price table', 'improveab-ab-testing' ), 'nmColumns' => __( 'Columns', 'improveab-ab-testing' ), 'nmColumn' => __( 'Column', 'improveab-ab-testing' ), 'nmCover' => __( 'Cover', 'improveab-ab-testing' ), 'nmButtons' => __( 'Buttons', 'improveab-ab-testing' ), 'nmQuote' => __( 'Quote', 'improveab-ab-testing' ), 'nmMediaText' => __( 'Media and text', 'improveab-ab-testing' ), 'nmTable' => __( 'Table', 'improveab-ab-testing' ), 'nmGallery' => __( 'Gallery', 'improveab-ab-testing' ), 'nmSection' => __( 'Section', 'improveab-ab-testing' ), 'nmHeader' => __( 'Header', 'improveab-ab-testing' ), 'nmFooter' => __( 'Footer', 'improveab-ab-testing' ), 'nmElement' => __( 'Element', 'improveab-ab-testing' ), 'advSummarySplit' => __( 'Traffic split and selector', 'improveab-ab-testing' ), 'splitEvenHelp' => __( 'Visitors split evenly between the versions.', 'improveab-ab-testing' ), ), ) ); } }