."), ±5 minutes). * Changes are stored in the plugin's own post meta/options and applied through filters, so * deleting the meta (or deactivating the plugin) reverts them. * * Naming (the plugin must never change the look of a visitor's site): every CSS class is "seolok-…", every id * "seolok_…"/"seolok-…", JS globals "SEOLOK_…", PHP functions/options/meta "seolok_…". No global CSS (html, body, *, * bare tag selectors) and no !important. Checked by src/lib/wp/plugin-css.test.ts in the SEOLOK repository. */ if (!defined('ABSPATH')) { exit; } define('SEOLOK_VERSION', '0.6.11'); define('SEOLOK_FILE', __FILE__); define('SEOLOK_SIGNATURE_WINDOW', 300); /* ------------------------------------------------------------------ helpers */ function seolok_option($name, $default = '') { $value = get_option('seolok_' . $name, $default); return is_string($value) ? trim($value) : $value; } function seolok_api_base() { $api = seolok_option('api', 'https://seolok.com'); return untrailingslashit($api !== '' ? $api : 'https://seolok.com'); } function seolok_is_tr() { return strpos(get_locale(), 'tr') === 0; } /** Tiny two-locale string table (TR sites get Turkish, everything else English). */ function seolok_t($key) { $strings = array( 'settings_title' => array('SEOLOK', 'SEOLOK'), 'site_id' => array('Site ID', 'Site kimliği'), 'token' => array('Plugin token', 'Eklenti token\'ı'), 'api' => array('SEOLOK URL', 'SEOLOK adresi'), 'help' => array('Copy the site ID and token from SEOLOK → site overview → WordPress plugin, then click "Test connection" there.', 'Site kimliğini ve token\'ı SEOLOK → site genel bakış → WordPress eklentisi kartından kopyalayın, sonra orada "Bağlantıyı test et"e tıklayın.'), 'saved' => array('Settings saved.', 'Ayarlar kaydedildi.'), 'push_button' => array('Get notifications', 'Bildirimleri aç'), 'push_dismiss' => array('Not now', 'Şimdi değil'), 'push_thanks' => array('Notifications enabled', 'Bildirimler açıldı'), 'network_title' => array('You may also like', 'Bunlar da ilgini çekebilir'), 'network_label' => array('Recommended by SEOLOK network', 'SEOLOK ağı önerisi'), 'not_connected' => array('SEOLOK is not connected yet. Paste the site ID and token in Settings → SEOLOK.', 'SEOLOK henüz bağlı değil. Ayarlar → SEOLOK sayfasına site kimliğini ve token\'ı yapıştır.'), 'go_settings' => array('Open settings', 'Ayarlara git'), 'load_failed' => array('Lokman cannot reach SEOLOK right now.', 'Lokman şu an SEOLOK\'a ulaşamıyor.'), 'bad_token' => array('SEOLOK did not accept this site\'s token. If you renewed the key in SEOLOK, paste the new one in Settings → SEOLOK.', 'SEOLOK bu sitenin anahtarını kabul etmedi. SEOLOK\'ta anahtarı yenilediysen yeni anahtarı Ayarlar → SEOLOK sayfasına yapıştır.'), 'retry' => array('Try again', 'Tekrar dene'), 'action_failed' => array('The change could not be sent to SEOLOK.', 'Değişiklik SEOLOK\'a iletilemedi.'), 'forbidden' => array('You are not allowed to do this.', 'Bunu yapmaya yetkin yok.'), 'open_lokman' => array('Open Lokman', 'Lokman\'ı aç'), 'link_settings' => array('Settings', 'Ayarlar'), 'link_lokman' => array('Lokman', 'Lokman'), 'site_note' => array('Connected SEOLOK site ID ends with:', 'Bağlı SEOLOK site kimliğinin sonu:'), 'token_saved' => array('Saved. Leave empty to keep it; paste a new one to replace it.', 'Kayıtlı. Boş bırakırsan aynısı kalır; değiştirmek için yenisini yapıştır.'), 'sync_on_done' => array('Live sync is on. The first report went to SEOLOK.', 'Canlı senkron açıldı. İlk rapor SEOLOK\'a gitti.'), 'sync_on_pending' => array('Live sync is on, but the first report could not reach SEOLOK yet; it will be tried again shortly.', 'Canlı senkron açıldı ama ilk rapor SEOLOK\'a ulaşamadı; birazdan yeniden denenecek.'), 'sync_off_done' => array('Live sync is off. Nothing more is sent.', 'Canlı senkron kapatıldı. Artık hiçbir şey gönderilmiyor.'), 'search_on_done' => array('Search engines are allowed in again.', 'Arama motorlarına yeniden açıldı.'), 'search_on_skip' => array('Search engines were already allowed in.', 'Arama motorları zaten açıktı.'), 'sync_label' => array('Live sync', 'Canlı senkron'), 'visitors_label' => array('Show SEOLOK to visitors', 'Ziyaretçilere SEOLOK göster'), 'visitors_help' => array('Off by default. When ticked, and only while you use these in SEOLOK: the notification button during a SEOLOK Boost campaign and, if your site joined the SEOLOK network, a "recommended" box with links to other member sites under your posts. Visitors\' browsers then talk to seolok.com (see the plugin\'s readme).', 'Varsayılan kapalı. İşaretlenirse ve yalnız SEOLOK\'ta bunları kullanırken: SEOLOK Boost kampanyası sırasında bildirim düğmesi ve siten SEOLOK ağına katıldıysa yazılarının altında diğer üye sitelere bağlantı veren "öneri" kutusu. Ziyaretçilerin tarayıcısı o zaman seolok.com ile konuşur (eklentinin readme dosyası).'), 'sync_state_on' => array('On. It is switched on or off on the Lokman page.', 'Açık. Lokman sayfasından açılıp kapatılır.'), 'sync_state_off' => array('Off. Nothing about your site is sent to SEOLOK until you switch it on on the Lokman page.', 'Kapalı. Lokman sayfasından açmadıkça siten hakkında SEOLOK\'a hiçbir şey gönderilmez.'), 'todo_title' => array('To do', 'Yapılacaklar'), 'todo_empty' => array('Nothing to do right now.', 'Şu an yapılacak bir şey yok.'), 'status_title' => array('Status', 'Durum'), 'site_note_hint' => array('If the links on this page say "not found" in SEOLOK, the account you are signed in with may not contain this site.', 'Bu sayfadaki bağlantılar SEOLOK\'ta "bulunamadı" derse, giriş yaptığın hesap bu siteyi içermiyor olabilir.'), ); if (!isset($strings[$key])) { return $key; } return $strings[$key][seolok_is_tr() ? 1 : 0]; } function seolok_signature($token, $timestamp, $body) { return hash_hmac('sha256', $timestamp . '.' . $body, $token); } /** Pure check used by the REST permission callback (kept separate for testing). */ function seolok_check_signature($token, $timestamp, $signature, $body, $now) { if ($token === '' || !$timestamp || abs($now - (int) $timestamp) > SEOLOK_SIGNATURE_WINDOW) { return false; } return hash_equals(seolok_signature($token, (int) $timestamp, $body), (string) $signature); } function seolok_detect_seo_plugin() { if (defined('WPSEO_VERSION')) { return 'yoast'; } if (defined('RANK_MATH_VERSION')) { return 'rankmath'; } return null; } /** Normalises a URL or path to "/path" without trailing slash ("/" for the home page). */ function seolok_normalize_path($url) { $path = wp_parse_url($url, PHP_URL_PATH); $path = is_string($path) ? $path : '/'; $path = '/' . trim($path, '/'); return $path === '/' ? '/' : $path; } function seolok_same_host($url) { $host = wp_parse_url($url, PHP_URL_HOST); $home = wp_parse_url(home_url('/'), PHP_URL_HOST); if (!$host || !$home) { return false; } $strip = function ($h) { return preg_replace('/^www\./', '', strtolower($h)); }; return $strip($host) === $strip($home); } /** * Resolves a public URL to what SEOLOK changes: a post/page ID, or the home page. * @return array{kind:string,id:int}|WP_Error */ function seolok_resolve_target($url) { if (!seolok_same_host($url)) { return new WP_Error('seolok_foreign_url', 'URL is not on this site', array('status' => 422)); } $post_id = url_to_postid($url); if ($post_id) { return array('kind' => 'post', 'id' => (int) $post_id); } $home_path = seolok_normalize_path(home_url('/')); if (seolok_normalize_path($url) === $home_path) { $front = (int) get_option('page_on_front'); return $front ? array('kind' => 'post', 'id' => $front) : array('kind' => 'home', 'id' => 0); } // a category, tag or other term archive (/idare-hukuku/ on a site that drops the category base is one), then a page of a post type url_to_postid() does not know $term = seolok_resolve_term($url); if ($term !== null) { return $term; } $by_path = seolok_resolve_post_by_path($url); if ($by_path > 0) { return array('kind' => 'post', 'id' => $by_path); } return new WP_Error('seolok_post_not_found', 'No post, page or category matches this URL', array('status' => 404)); } /** The term (category, tag, custom taxonomy) whose archive address is this URL: the last path segment is its slug, and the term's own link must be this very address. */ function seolok_resolve_term($url) { if (!function_exists('get_taxonomies') || !function_exists('get_term_by') || !function_exists('get_term_link')) { return null; } $path = seolok_normalize_path($url); $segments = explode('/', trim($path, '/')); $slug = rawurldecode((string) end($segments)); if ($slug === '') { return null; } foreach ((array) get_taxonomies(array('public' => true), 'names') as $taxonomy) { $term = get_term_by('slug', $slug, $taxonomy); if (!$term || is_wp_error($term) || !isset($term->term_id)) { continue; } $link = get_term_link($term, $taxonomy); if (is_string($link) && !is_wp_error($link) && seolok_normalize_path($link) === $path) { return array('kind' => 'term', 'id' => (int) $term->term_id, 'taxonomy' => (string) $taxonomy); } } return null; } /** A page of any public post type found by its path (url_to_postid() misses some custom post types); its own permalink must be this address. */ function seolok_resolve_post_by_path($url) { if (!function_exists('get_page_by_path')) { return 0; } $path = seolok_normalize_path($url); $types = function_exists('get_post_types') ? array_values((array) get_post_types(array('public' => true), 'names')) : array('post', 'page'); $post = get_page_by_path(trim($path, '/'), OBJECT, $types ? $types : array('post', 'page')); if (!$post || !isset($post->ID)) { return 0; } $link = get_permalink($post->ID); return is_string($link) && seolok_normalize_path($link) === $path ? (int) $post->ID : 0; } /** The SEO plugin's own field for a value SEOLOK sets on a TERM (Rank Math keeps term meta; Yoast one option for all terms). */ function seolok_term_seo_field($key) { $fields = array( 'yoast' => array('title' => 'wpseo_title', 'metadesc' => 'wpseo_desc', 'focuskw' => 'wpseo_focuskw'), 'rankmath' => array('title' => 'rank_math_title', 'metadesc' => 'rank_math_description', 'focuskw' => 'rank_math_focus_keyword'), ); $plugin = seolok_detect_seo_plugin(); return $plugin && isset($fields[$plugin][$key]) ? $fields[$plugin][$key] : null; } function seolok_term_seo_get($target, $field) { if (seolok_detect_seo_plugin() === 'yoast') { $all = get_option('wpseo_taxonomy_meta', array()); return isset($all[$target['taxonomy']][$target['id']][$field]) ? (string) $all[$target['taxonomy']][$target['id']][$field] : ''; } return (string) get_term_meta($target['id'], $field, true); } /** Writes the SEO plugin's term field ('' removes it). */ function seolok_term_seo_put($target, $field, $value) { if (seolok_detect_seo_plugin() === 'yoast') { $all = get_option('wpseo_taxonomy_meta', array()); $all = is_array($all) ? $all : array(); if ($value === '') { unset($all[$target['taxonomy']][$target['id']][$field]); } else { $all[$target['taxonomy']][$target['id']][$field] = $value; } update_option('wpseo_taxonomy_meta', $all); return; } if ($value === '') { delete_term_meta($target['id'], $field); } else { update_term_meta($target['id'], $field, wp_slash($value)); } } function seolok_term_seo_set($target, $key, $value) { $field = seolok_term_seo_field($key); if ($field === null) { return; } if (!metadata_exists('term', $target['id'], '_seolok_orig_' . $field)) { add_term_meta($target['id'], '_seolok_orig_' . $field, wp_slash(wp_json_encode(array('v' => seolok_term_seo_get($target, $field)))), true); } seolok_term_seo_put($target, $field, (string) $value); } function seolok_term_seo_reset($target, $key) { $field = seolok_term_seo_field($key); if ($field === null || !metadata_exists('term', $target['id'], '_seolok_orig_' . $field)) { return; } $orig = json_decode((string) get_term_meta($target['id'], '_seolok_orig_' . $field, true), true); seolok_term_seo_put($target, $field, is_array($orig) && isset($orig['v']) ? (string) $orig['v'] : ''); delete_term_meta($target['id'], '_seolok_orig_' . $field); } /** A term's own name (what its archive shows without an SEO title). */ function seolok_term_name($target) { $term = function_exists('get_term') ? get_term($target['id'], $target['taxonomy']) : null; return $term && !is_wp_error($term) && isset($term->name) ? (string) $term->name : ''; } /** * The active SEO plugin's own post field for a value SEOLOK sets, so its editor box (and its score) shows what the page really uses. * SEOLOK's own value (`_seolok_*`) stays the source the front end filters read; this is the copy the editor shows. */ function seolok_seo_field($key) { $fields = array( 'yoast' => array('title' => '_yoast_wpseo_title', 'metadesc' => '_yoast_wpseo_metadesc', 'focuskw' => '_yoast_wpseo_focuskw'), 'rankmath' => array('title' => 'rank_math_title', 'metadesc' => 'rank_math_description', 'focuskw' => 'rank_math_focus_keyword'), ); $plugin = seolok_detect_seo_plugin(); return $plugin && isset($fields[$plugin][$key]) ? $fields[$plugin][$key] : null; } /** The title the page shows today: SEOLOK's own value, else the SEO plugin's field (unless it is a template such as "%title%"), else the post title. */ function seolok_effective_title($target) { $own = (string) seolok_get_value($target, 'title'); if ($own !== '') { return $own; } if ($target['kind'] === 'term') { $field = seolok_term_seo_field('title'); $plugin = $field !== null ? seolok_term_seo_get($target, $field) : ''; return $plugin !== '' && strpos($plugin, '%') === false ? $plugin : seolok_term_name($target); } if ($target['kind'] !== 'post') { return (string) get_bloginfo('name'); } $field = seolok_seo_field('title'); $plugin = $field !== null ? (string) get_post_meta($target['id'], $field, true) : ''; if ($plugin !== '' && strpos($plugin, '%') === false) { return $plugin; } return (string) get_the_title($target['id']); } /** * Every title the page may be showing: SEOLOK's own, the SEO plugin's field with its %variables% dropped (a template such as * "%title% %sep% %sitename%" is not a title), the post's or term's own name. A suggestion is "written for this page" when its * expected title matches ANY of them (the site name at the end and HTML entities aside, see seolok_title_key). */ function seolok_title_candidates($target) { $out = array(seolok_effective_title($target)); $plugin = ''; if ($target['kind'] === 'post') { $field = seolok_seo_field('title'); $plugin = $field !== null ? (string) get_post_meta($target['id'], $field, true) : ''; $out[] = (string) get_the_title($target['id']); } elseif ($target['kind'] === 'term') { $field = seolok_term_seo_field('title'); $plugin = $field !== null ? seolok_term_seo_get($target, $field) : ''; $out[] = seolok_term_name($target); } if ($plugin !== '') { $out[] = trim((string) preg_replace('/\s*%[a-z_]+%\s*/i', ' ', $plugin)); } return array_values(array_unique(array_filter(array_map('trim', $out), 'strlen'))); } /** "… - Avukat Ferhat GEBEŞ" → "…": the site's own name at the end of a title is not part of what the page is about. */ function seolok_strip_site_name($title) { $site = trim((string) get_bloginfo('name')); $t = (string) $title; if ($site === '') { return $t; } foreach (array(' | ', ' – ', ' — ', ' - ', ' · ', ' : ') as $sep) { $tail = $sep . $site; if (mb_strlen($t) > mb_strlen($tail) && mb_strtolower(mb_substr($t, -mb_strlen($tail)), 'UTF-8') === mb_strtolower($tail, 'UTF-8')) { return mb_substr($t, 0, mb_strlen($t) - mb_strlen($tail)); } } return $t; } /** Two titles are the same title whatever the letter case, the punctuation or a short brand suffix (" | Site"). */ function seolok_title_key($title) { // entities a crawler may have kept ("Osmanlı'da"), then the site's name, then a short brand at the end $t = seolok_strip_site_name(html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5, 'UTF-8')); foreach (array(' | ', ' – ', ' — ', ' - ', ' · ') as $sep) { $at = strrpos($t, $sep); if ($at !== false && $at > 0 && mb_strlen(trim(substr($t, $at + strlen($sep)))) <= 30) { $t = substr($t, 0, $at); break; } } $t = str_replace(array('İ', 'I'), array('i', 'ı'), $t); // Turkish letters: mb_strtolower('İ') is "i" with a combining dot, which "i" never matches $t = function_exists('mb_strtolower') ? mb_strtolower($t, 'UTF-8') : strtolower($t); return trim((string) preg_replace('/[^\p{L}\p{N}]+/u', ' ', $t)); } /** The suggestion was written for `$expected`; a page whose title changed meanwhile (an editor's own words) is not overwritten in silence. */ function seolok_title_changed_since($target, $expected) { $expected = trim((string) $expected); if ($expected === '') { return false; } $want = seolok_title_key($expected); if ($want === '') { return false; } $candidates = seolok_title_candidates($target); foreach ($candidates as $candidate) { if (seolok_title_key($candidate) === $want) { return false; } } return count($candidates) > 0; } /** Writes the SEO plugin's field of a post, first remembering (once) what it held before SEOLOK ever touched it. */ function seolok_seo_field_set($post_id, $key, $value) { $field = seolok_seo_field($key); if ($field === null) { return; } if (!metadata_exists('post', $post_id, '_seolok_orig_' . $field)) { add_post_meta($post_id, '_seolok_orig_' . $field, wp_slash(wp_json_encode(array('v' => (string) get_post_meta($post_id, $field, true)))), true); } update_post_meta($post_id, $field, wp_slash($value)); } /** Puts the SEO plugin's field back to what it held before SEOLOK (removed when it was empty) and forgets the remembered value. */ function seolok_seo_field_reset($post_id, $key) { $field = seolok_seo_field($key); if ($field === null || !metadata_exists('post', $post_id, '_seolok_orig_' . $field)) { return; } $orig = json_decode((string) get_post_meta($post_id, '_seolok_orig_' . $field, true), true); $value = is_array($orig) && isset($orig['v']) ? (string) $orig['v'] : ''; if ($value === '') { delete_post_meta($post_id, $field); } else { update_post_meta($post_id, $field, wp_slash($value)); } delete_post_meta($post_id, '_seolok_orig_' . $field); } function seolok_get_value($target, $key) { if ($target['kind'] === 'post') { return get_post_meta($target['id'], '_seolok_' . $key, true); } if ($target['kind'] === 'term') { return get_term_meta($target['id'], '_seolok_' . $key, true); } return get_option('seolok_home_' . $key, ''); } function seolok_set_value($target, $key, $value) { if ($target['kind'] === 'post') { update_post_meta($target['id'], '_seolok_' . $key, $value); if ($key === 'title' || $key === 'metadesc') { seolok_seo_field_set($target['id'], $key, (string) $value); } } elseif ($target['kind'] === 'term') { update_term_meta($target['id'], '_seolok_' . $key, $value); if ($key === 'title' || $key === 'metadesc') { seolok_term_seo_set($target, $key, (string) $value); } } else { update_option('seolok_home_' . $key, $value, false); } } /** Undo: puts a previous value back; an empty previous value removes SEOLOK's own value (the page's original shows again). */ function seolok_restore_value($target, $key, $value) { if ($value !== '') { seolok_set_value($target, $key, $value); return; } if ($target['kind'] === 'post') { delete_post_meta($target['id'], '_seolok_' . $key); if ($key === 'title' || $key === 'metadesc') { seolok_seo_field_reset($target['id'], $key); } } elseif ($target['kind'] === 'term') { delete_term_meta($target['id'], '_seolok_' . $key); if ($key === 'title' || $key === 'metadesc') { seolok_term_seo_reset($target, $key); } } else { delete_option('seolok_home_' . $key); } } /** * After a title is applied from the SEO titles screen: the search term goes into the SEO plugin's focus keyword and the description * into the post's excerpt, each only when it is empty (never over what the owner wrote). What was filled is remembered so undo clears it. */ function seolok_title_extras_set($target, $keyword, $desc) { if ($target['kind'] === 'term') { $field = seolok_term_seo_field('focuskw'); if ($field !== null && $keyword !== '' && trim(seolok_term_seo_get($target, $field)) === '') { seolok_term_seo_put($target, $field, $keyword); update_term_meta($target['id'], '_seolok_set_focuskw', wp_slash($keyword)); } return; } if ($target['kind'] !== 'post') { return; } $id = $target['id']; $field = seolok_seo_field('focuskw'); if ($field !== null && $keyword !== '' && trim((string) get_post_meta($id, $field, true)) === '') { update_post_meta($id, $field, wp_slash($keyword)); update_post_meta($id, '_seolok_set_focuskw', wp_slash($keyword)); } $post = get_post($id); if ($post && $desc !== '' && trim((string) $post->post_excerpt) === '' && post_type_supports($post->post_type, 'excerpt')) { wp_update_post(wp_slash(array('ID' => $id, 'post_excerpt' => $desc))); update_post_meta($id, '_seolok_set_excerpt', wp_slash($desc)); } } /** Undo of seolok_title_extras_set: clears only what SEOLOK filled and nobody changed since. */ function seolok_title_extras_undo($target) { if ($target['kind'] === 'term') { $field = seolok_term_seo_field('focuskw'); $set_kw = (string) get_term_meta($target['id'], '_seolok_set_focuskw', true); if ($field !== null && $set_kw !== '' && seolok_term_seo_get($target, $field) === $set_kw) { seolok_term_seo_put($target, $field, ''); } delete_term_meta($target['id'], '_seolok_set_focuskw'); return; } if ($target['kind'] !== 'post') { return; } $id = $target['id']; $field = seolok_seo_field('focuskw'); $set_kw = (string) get_post_meta($id, '_seolok_set_focuskw', true); if ($field !== null && $set_kw !== '' && (string) get_post_meta($id, $field, true) === $set_kw) { delete_post_meta($id, $field); } delete_post_meta($id, '_seolok_set_focuskw'); $set_ex = (string) get_post_meta($id, '_seolok_set_excerpt', true); $post = get_post($id); if ($post && $set_ex !== '' && (string) $post->post_excerpt === $set_ex) { wp_update_post(wp_slash(array('ID' => $id, 'post_excerpt' => ''))); } delete_post_meta($id, '_seolok_set_excerpt'); } /** Value SEOLOK set for the currently rendered page (singular post/page or the posts home). */ function seolok_current_value($key) { if (is_singular()) { $value = get_post_meta(get_queried_object_id(), '_seolok_' . $key, true); return is_string($value) ? $value : ''; } // a category, tag or other term archive: SEOLOK's value lives on the term if (function_exists('is_category') && function_exists('is_tax') && function_exists('is_tag') && (is_category() || is_tag() || is_tax())) { $term = get_queried_object(); $value = $term && isset($term->term_id) ? get_term_meta((int) $term->term_id, '_seolok_' . $key, true) : ''; return is_string($value) ? $value : ''; } if (is_front_page() || is_home()) { return (string) get_option('seolok_home_' . $key, ''); } return ''; } /* ------------------------------------------- link cleanup (replace / remove) */ /** Content backups kept per post (each one holds the whole post_content). */ define('SEOLOK_MAX_BACKUPS', 3); function seolok_is_web_url($url) { return is_string($url) && preg_match('#^https?://[^/\s?\#]+#i', $url) === 1; } /** * Comparison key of a link: host without "www." (lowercase) + decoded path without trailing slash + query. * Scheme and fragment are ignored; "/path" resolves against the site's host. Empty for what is not a page link * (anchors, mailto:, tel:, javascript:, relative paths without a leading slash). */ function seolok_link_key($href) { $href = trim(html_entity_decode((string) $href, ENT_QUOTES | ENT_HTML5, 'UTF-8')); if ($href === '' || $href[0] === '#' || preg_match('#^(mailto|tel|sms|javascript|data):#i', $href) === 1) { return ''; } if (strpos($href, '//') === 0) { $href = 'https:' . $href; } elseif ($href[0] === '/') { $home = wp_parse_url(home_url('/')); if (!is_array($home) || empty($home['host'])) { return ''; } $href = 'https://' . $home['host'] . (isset($home['port']) ? ':' . $home['port'] : '') . $href; } $parts = wp_parse_url($href); if (!is_array($parts) || empty($parts['host'])) { return ''; } $host = preg_replace('/^www\./', '', strtolower($parts['host'])); $path = '/' . trim(rawurldecode(isset($parts['path']) ? $parts['path'] : '/'), '/'); return $host . $path . (isset($parts['query']) && $parts['query'] !== '' ? '?' . $parts['query'] : ''); } /** * Pure editor: in `$content`, every whose href is the same page as `$from` is either re-pointed to `$to` * ($mode "replace": only the href changes, every other attribute and the link text stay) or removed with its text kept * ($mode "unlink"). Block-editor buttons are never unlinked (it would break the block); they are counted as skipped. * @return array{content:string,changed:int,skipped:int} */ function seolok_edit_links($content, $from, $mode, $to = '') { $from_key = seolok_link_key($from); $counts = array('changed' => 0, 'skipped' => 0); if ($from_key === '') { return array('content' => $content, 'changed' => 0, 'skipped' => 0); } $new_href = $mode === 'replace' ? esc_attr(esc_url_raw($to)) : ''; // attribute-aware: a ">" inside a quoted attribute (title="a > b") does not end the tag $pattern = '#"\']|"[^"]*"|\'[^\']*\')*)>(.*?)#is'; $out = preg_replace_callback($pattern, function ($m) use ($from_key, $mode, $new_href, &$counts) { $attrs = $m[1]; if (!preg_match('/\bhref\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i', $attrs, $h)) { return $m[0]; } $href = (isset($h[2]) && $h[2] !== '') ? $h[2] : $h[1]; if (seolok_link_key($href) !== $from_key) { return $m[0]; } if ($mode === 'unlink') { if (preg_match('/\bclass\s*=\s*(?:"[^"]*|\'[^\']*)\bwp-block-button__link\b/i', $attrs) === 1) { $counts['skipped']++; return $m[0]; } $counts['changed']++; return $m[2]; } $attrs = preg_replace_callback('/(\bhref\s*=\s*)(?:"[^"]*"|\'[^\']*\')/i', function ($a) use ($new_href) { return $a[1] . '"' . $new_href . '"'; }, $attrs, 1); $counts['changed']++; return '' . $m[2] . substr($m[0], 3 + strlen($m[1]) + strlen($m[2])); }, $content); return array('content' => is_string($out) ? $out : $content, 'changed' => $counts['changed'], 'skipped' => $counts['skipped']); } /** Page builders keep the real content in their own data: editing post_content would change nothing visible (or be overwritten). */ function seolok_page_builder($post_id) { if (get_post_meta($post_id, '_elementor_edit_mode', true) === 'builder') { return 'elementor'; } if (get_post_meta($post_id, '_fl_builder_enabled', true)) { return 'beaver'; } return null; } function seolok_get_backups($post_id) { $list = get_post_meta($post_id, '_seolok_backups', true); return is_array($list) ? $list : array(); } function seolok_save_backups($post_id, $list) { // update_post_meta() unslashes its input: slash first so backslashes in the content survive update_post_meta($post_id, '_seolok_backups', wp_slash(array_values($list))); } /** Writes post_content without WordPress's kses filter (an unauthenticated REST call would otherwise strip iframes, styles, svg … from the whole post). */ function seolok_write_content($post_id, $content) { kses_remove_filters(); $result = wp_update_post(wp_slash(array('ID' => $post_id, 'post_content' => $content)), true); kses_init(); return $result; } /** * Re-points or removes the links to `$from` inside one post. The previous content is saved first (last * SEOLOK_MAX_BACKUPS kept) together with the fingerprint of what we wrote, so an undo is only done while the * post is still exactly as we left it. * @return array|WP_Error */ function seolok_apply_link_edit($post_id, $mode, $from, $to) { $builder = seolok_page_builder($post_id); if ($builder !== null) { return new WP_Error('seolok_builder_unsupported', 'This page is built with ' . $builder . '; its links are not in the post content', array('status' => 422)); } $before = (string) get_post_field('post_content', $post_id, 'raw'); $edit = seolok_edit_links($before, $from, $mode, $to); if ($edit['changed'] === 0) { return new WP_Error('seolok_link_not_found', $edit['skipped'] > 0 ? 'Only block-editor buttons link there; they cannot be removed safely' : 'No such link in the content of this page (it may come from a menu, widget or template)', array('status' => 404)); } $backups = seolok_get_backups($post_id); $id = wp_generate_uuid4(); $backups[] = array('id' => $id, 'content' => $before, 'after' => '', 'at' => time()); $backups = array_slice($backups, -SEOLOK_MAX_BACKUPS); seolok_save_backups($post_id, $backups); $result = seolok_write_content($post_id, $edit['content']); if (is_wp_error($result) || !$result) { seolok_save_backups($post_id, array_filter($backups, function ($b) use ($id) { return $b['id'] !== $id; })); return is_wp_error($result) ? $result : new WP_Error('seolok_write_failed', 'The page could not be saved', array('status' => 500)); } foreach ($backups as $i => $b) { if ($b['id'] === $id) { $backups[$i]['after'] = md5((string) get_post_field('post_content', $post_id, 'raw')); } } seolok_save_backups($post_id, $backups); return array('applied' => true, 'postId' => (int) $post_id, 'previous' => array('backupId' => $id), 'changed' => $edit['changed'], 'skipped' => $edit['skipped'], 'via' => 'core'); } /** Undo of seolok_apply_link_edit(): only while the post still matches what we wrote (otherwise the visitor's later edits would be lost). */ function seolok_restore_content($post_id, $backup_id) { foreach (seolok_get_backups($post_id) as $b) { if ($b['id'] !== $backup_id) { continue; } if ($b['after'] === '' || md5((string) get_post_field('post_content', $post_id, 'raw')) !== $b['after']) { return new WP_Error('seolok_changed_since', 'The page was edited after SEOLOK changed it; restore it from WordPress revisions instead', array('status' => 409)); } $result = seolok_write_content($post_id, (string) $b['content']); if (is_wp_error($result) || !$result) { return is_wp_error($result) ? $result : new WP_Error('seolok_write_failed', 'The page could not be saved', array('status' => 500)); } seolok_save_backups($post_id, array_filter(seolok_get_backups($post_id), function ($x) use ($backup_id) { return $x['id'] !== $backup_id; })); return array('applied' => true, 'postId' => (int) $post_id, 'previous' => null, 'via' => 'core'); } return new WP_Error('seolok_backup_missing', 'No saved version with this id (only the last ' . SEOLOK_MAX_BACKUPS . ' changes of a page are kept)', array('status' => 404)); } /* ------------------------------------------------- page address (slug) change */ /** What the plugin remembers on a page it gave a new address: a list of {id, from, to, 301s replaced, at} (the undo and a repeated request are checked against it). */ define('SEOLOK_RENAME_META', '_seolok_rename'); define('SEOLOK_MAX_RENAME_MARKERS', 10); /** Percent-decoded, non-empty path segments of a URL. */ function seolok_path_segments($url) { $path = wp_parse_url((string) $url, PHP_URL_PATH); $parts = array(); foreach (explode('/', is_string($path) ? $path : '') as $part) { if ($part !== '') { $parts[] = rawurldecode($part); } } return $parts; } function seolok_last_segment($url) { $parts = seolok_path_segments($url); return $parts ? (string) end($parts) : ''; } /** The folder a page lives in, lower-case ("/blog" of ".../blog/yazi/", "/" at the top level): addresses are compared, not shown. */ function seolok_parent_path($url) { $parts = seolok_path_segments($url); array_pop($parts); return strtolower('/' . implode('/', $parts)); } /** The same address with another last path segment (always with a trailing slash); the folder keeps the spelling it has. */ function seolok_with_last_segment($url, $slug) { $parts = wp_parse_url((string) $url); $path = is_array($parts) && isset($parts['path']) ? rtrim($parts['path'], '/') : ''; $cut = strrpos($path, '/'); $path = ($cut === false ? '' : substr($path, 0, $cut)) . '/' . $slug . '/'; return (isset($parts['scheme']) ? $parts['scheme'] : 'https') . '://' . (isset($parts['host']) ? $parts['host'] : '') . (isset($parts['port']) ? ':' . $parts['port'] : '') . $path; } /** The public address of a path of this site (paths in the redirect table already carry a sub-folder install's folder). */ function seolok_path_url($path) { $home = wp_parse_url(home_url('/')); return (isset($home['scheme']) ? $home['scheme'] : 'https') . '://' . (isset($home['host']) ? $home['host'] : '') . (isset($home['port']) ? ':' . $home['port'] : '') . rtrim((string) $path, '/') . '/'; } /** Comparison form of an address: only its path, decoded and lower-case (WordPress matches slugs case-insensitively; a crawl, a browser and WordPress spell %-codes and folders differently). */ function seolok_path_key($url) { return strtolower(rawurldecode(seolok_normalize_path($url))); } /** Plain a-z / 0-9 / hyphen slug with at least one letter (WordPress adds "-2" to a page slug that is only digits). */ function seolok_valid_slug($slug) { return is_string($slug) && strlen($slug) <= 190 && preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) === 1 && preg_match('/^[0-9]+$/D', $slug) !== 1; } function seolok_get_redirects() { $redirects = get_option('seolok_redirects', array()); return is_array($redirects) ? $redirects : array(); } /** * A 301 from `$source` (a path) to `$target` is fine unless the target already redirects (through the stored 301s) back to it: that would * send visitors round in circles. The chain is followed with the very lookup a request gets (seolok_find_redirect_key: letter case and * %-code case do not hide a loop) and stops where a request would stay. */ function seolok_redirect_is_safe($redirects, $source, $target) { $redirects[$source] = $target; // as it would be stored $hop = seolok_normalize_path($target); for ($i = 0; $i < 10; $i++) { $key = seolok_find_redirect_key($redirects, $hop); if ($key === null) { return true; } $next = seolok_normalize_path($redirects[$key]); if ($next === $key || $next === $hop) { return true; // a request would stay here (see the template_redirect hook) } if ($key === $source) { return false; // the chain leads back to this very redirect } $hop = $next; } return true; } /** * The one way a 301 of this plugin is stored (SEOLOK's signed "redirect" and the "404 addresses" screen both come here): `$from` a * path of this site, `$target` an address of this site. Never the home page, never to itself, never a circle. A 404 address that got * its 301 leaves the 404 list. `$by`: who asked ("seolok" or "wp"). @return array{previous:?string}|WP_Error */ function seolok_store_redirect($from, $target, $by) { if ($from === seolok_normalize_path($target) || $from === seolok_normalize_path(home_url('/'))) { return new WP_Error('seolok_invalid_redirect', 'Refusing to redirect the home page or to itself', array('status' => 422)); } $redirects = seolok_get_redirects(); if (!seolok_redirect_is_safe($redirects, $from, $target)) { return new WP_Error('seolok_invalid_redirect', 'This redirect would loop: the target already redirects back here', array('status' => 422)); } $previous = isset($redirects[$from]) ? $redirects[$from] : null; $redirects[$from] = $target; update_option('seolok_redirects', $redirects, true); seolok_404_redirected($from, $target, $by); return array('previous' => $previous); } /** Whether a published page answers at this path of the site. */ function seolok_path_has_page($path) { $id = url_to_postid(seolok_path_url($path)); $post = $id ? get_post($id) : null; return $post && $post->post_status === 'publish'; } /** The key of the stored 301 that answers a request (the path as received, "//x" being one path; lower-case, and with the install's folder in front, as fallbacks), or null. */ function seolok_find_redirect_key($redirects, $request_uri) { $path = seolok_normalize_path('/' . ltrim((string) $request_uri, '/')); $home = rtrim(seolok_normalize_path(home_url('/')), '/'); foreach (array_unique(array($path, strtolower($path), $home . $path, strtolower($home . $path))) as $key) { if (isset($redirects[$key])) { return $key; } } return null; } /** * After an address changed: ask the common page-cache plugins to forget the addresses involved. Without it the answer of the OLD state * keeps being served (LiteSpeed keeps a 404 for an hour and a 301 as well, whatever the page says now: the new address stays "not found", * or the old one keeps redirecting after an undo). Best effort: each call exists only when that plugin is installed. */ function seolok_purge_cached_urls($urls) { foreach (array_unique(array_filter($urls)) as $url) { do_action('litespeed_purge_url', $url); if (function_exists('rocket_clean_files')) { rocket_clean_files(array($url)); // WP Rocket } if (function_exists('w3tc_flush_url')) { w3tc_flush_url($url); // W3 Total Cache } if (function_exists('wpsc_delete_url_cache')) { wpsc_delete_url_cache($url); // WP Super Cache } } } /* ---- the marker list */ function seolok_get_rename_markers($post_id) { $list = get_post_meta($post_id, SEOLOK_RENAME_META, true); return is_array($list) ? array_values(array_filter($list, 'is_array')) : array(); } function seolok_save_rename_markers($post_id, $list) { $list = array_slice(array_values($list), -SEOLOK_MAX_RENAME_MARKERS); if ($list) { update_post_meta($post_id, SEOLOK_RENAME_META, wp_slash($list)); } else { delete_post_meta($post_id, SEOLOK_RENAME_META); } } /** The marker of exactly this change of exactly this page (a copy of the page's meta on a duplicate carries another id), or null. */ function seolok_find_rename_marker($post_id, $from, $to) { foreach (seolok_get_rename_markers($post_id) as $marker) { if (isset($marker['id'], $marker['from'], $marker['to']) && (int) $marker['id'] === (int) $post_id && $marker['from'] === $from && $marker['to'] === $to) { return $marker; } } return null; } /** Adds (or replaces) the marker of this change. */ function seolok_put_rename_marker($post_id, $marker) { $list = array(); foreach (seolok_get_rename_markers($post_id) as $m) { if (!(isset($m['from'], $m['to']) && $m['from'] === $marker['from'] && $m['to'] === $marker['to'])) { $list[] = $m; } } $list[] = $marker; seolok_save_rename_markers($post_id, $list); } function seolok_drop_rename_marker($post_id, $from, $to) { $list = array(); foreach (seolok_get_rename_markers($post_id) as $m) { if (!(isset($m['from'], $m['to']) && $m['from'] === $from && $m['to'] === $to)) { $list[] = $m; } } seolok_save_rename_markers($post_id, $list); } /** A change that did not happen: its marker goes, and an earlier genuine marker of the same change (put back by hand, undo still wanted) comes back. */ function seolok_forget_rename_marker($post_id, $from, $to, $earlier) { if ($earlier !== null) { seolok_put_rename_marker($post_id, $earlier); } else { seolok_drop_rename_marker($post_id, $from, $to); } } /** Writes post_name only (kses off while saving, for the same reason as seolok_write_content). */ function seolok_write_slug($post_id, $slug) { kses_remove_filters(); $result = wp_update_post(wp_slash(array('ID' => $post_id, 'post_name' => $slug)), true); kses_init(); return $result; } function seolok_rename_refused($message) { return new WP_Error('seolok_rename_refused', $message, array('status' => 422)); } /** Why this page may not get another address (null = it may). Applies to the change and to putting the old address back. */ function seolok_rename_blocker($post, $post_id) { if (!$post || $post->post_status !== 'publish') { return 'Only a published page can get a new address'; } if ((int) get_option('page_on_front') === (int) $post_id || (int) get_option('page_for_posts') === (int) $post_id) { return 'The front page and the posts page keep their address'; } if (is_post_type_hierarchical($post->post_type) && get_posts(array('post_type' => $post->post_type, 'post_parent' => $post_id, 'post_status' => 'any', 'numberposts' => 1, 'fields' => 'ids'))) { return 'This page has child pages; their addresses would change too'; } return null; } /** * A repeated request for an address change that already happened (the first answer may have been lost on a slow site, or the first * request died after saving the page but before storing the 301): the page is at `$new_url` and carries SEOLOK's marker for exactly * this change. A missing 301 is put back. Null when that is not the case. * @return array|null */ function seolok_completed_rename($old_url, $new_url) { if (!seolok_same_host($old_url) || !seolok_same_host($new_url)) { return null; } $post_id = url_to_postid($new_url); $post = $post_id ? get_post($post_id) : null; if (!$post) { return null; } $to = seolok_last_segment($new_url); $from = strtolower(seolok_last_segment($old_url)); $marker = seolok_find_rename_marker($post_id, $from, $to); if ($marker === null || (string) $post->post_name !== $to || seolok_parent_path($old_url) !== seolok_parent_path($new_url)) { return null; } $permalink = (string) get_permalink($post_id); $old_path = seolok_normalize_path(seolok_with_last_segment($permalink, $from)); $redirects = seolok_get_redirects(); if (seolok_find_redirect_key($redirects, $old_path) === null) { // (a 301 from the new address that was there before is in the marker: the first request noted it before saving) $redirects[$old_path] = $permalink; $new_key = seolok_find_redirect_key($redirects, seolok_normalize_path($permalink)); if ($new_key !== null && $new_key !== $old_path) { unset($redirects[$new_key]); } update_option('seolok_redirects', $redirects, true); seolok_purge_cached_urls(array(seolok_with_last_segment($permalink, $from), $permalink)); } return array('applied' => true, 'postId' => (int) $post_id, 'previous' => array('slug' => $from, 'redirectFromOld' => isset($marker['redirectFromOld']) ? $marker['redirectFromOld'] : null, 'redirectFromNew' => isset($marker['redirectFromNew']) ? $marker['redirectFromNew'] : null), 'newUrl' => $permalink, 'via' => 'core'); } /** * Gives a published page a new address: only its last path segment changes (same folder, plain slug) and the old address gets * a 301 to the new one. Nothing is saved when another page has that address, WordPress would not give exactly that address * (a taken, reserved or numeric slug), the page is the front page or has child pages (their addresses would change too, without * a redirect). The answer says what was there before (old slug and the 301s this replaced) so seolok_restore_slug() can put it back. * @return array|WP_Error */ function seolok_rename_slug($post_id, $old_url, $new_url) { $post = get_post($post_id); $blocker = seolok_rename_blocker($post, $post_id); if ($blocker !== null) { return seolok_rename_refused($blocker); } $old_slug = (string) $post->post_name; $new_slug = seolok_last_segment($new_url); if (!seolok_same_host($new_url) || !empty(wp_parse_url($new_url, PHP_URL_QUERY)) || !empty(wp_parse_url($old_url, PHP_URL_QUERY)) || !seolok_valid_slug($new_slug) || $new_slug === $old_slug || seolok_parent_path($old_url) !== seolok_parent_path($new_url) || strtolower(seolok_last_segment($old_url)) !== strtolower($old_slug)) { return seolok_rename_refused('Not a plain address change of this page (same folder, only letters, digits and hyphens)'); } $other = url_to_postid($new_url); if (($other && (int) $other !== (int) $post_id) || wp_unique_post_slug($new_slug, $post_id, $post->post_status, $post->post_type, $post->post_parent) !== $new_slug) { return new WP_Error('seolok_slug_taken', 'Another page, a reserved word or a number-only slug stands in the way of this address', array('status' => 409)); } $old_permalink = (string) get_permalink($post_id); $old_path = seolok_normalize_path($old_permalink); $planned_url = seolok_with_last_segment($old_permalink, $new_slug); // what the 301 table says about both addresses NOW (looked up like a request would, so spelling does not hide anything) $redirects = seolok_get_redirects(); $old_key = seolok_find_redirect_key($redirects, $old_path); $new_key = seolok_find_redirect_key($redirects, seolok_normalize_path($planned_url)); $previous = array( 'slug' => $old_slug, // a leftover 301 from an earlier change of this very page (it points to the new address) is not something to bring back 'redirectFromOld' => $old_key !== null && seolok_path_key($redirects[$old_key]) !== seolok_path_key($planned_url) ? $redirects[$old_key] : null, 'redirectFromNew' => $new_key !== null ? $redirects[$new_key] : null, ); // the marker first, with all of that: if the request dies between the save and the 301, the repeated request finds it and finishes the job $earlier = seolok_find_rename_marker($post_id, $old_slug, $new_slug); $marker = array('id' => (int) $post_id, 'from' => $old_slug, 'to' => $new_slug, 'redirectFromOld' => $previous['redirectFromOld'], 'redirectFromNew' => $previous['redirectFromNew'], 'at' => time()); seolok_put_rename_marker($post_id, $marker); $result = seolok_write_slug($post_id, $new_slug); if (is_wp_error($result) || !$result) { seolok_forget_rename_marker($post_id, $old_slug, $new_slug, $earlier); return is_wp_error($result) ? $result : new WP_Error('seolok_write_failed', 'The page could not be saved', array('status' => 500)); } $permalink = (string) get_permalink($post_id); if ((string) get_post_field('post_name', $post_id) !== $new_slug || seolok_path_key($permalink) !== seolok_path_key($new_url)) { seolok_write_slug($post_id, $old_slug); // WordPress chose another address (it knows a clash we do not): put the old one back seolok_forget_rename_marker($post_id, $old_slug, $new_slug, $earlier); return new WP_Error('seolok_slug_taken', 'WordPress would not give this page exactly that address', array('status' => 409)); } // the 301 is stored under the spelling WordPress itself serves (a request is compared with it as received) $new_path = seolok_normalize_path($permalink); $redirects = seolok_get_redirects(); if ($old_key !== null && $old_key !== $old_path && $previous['redirectFromOld'] === null) { unset($redirects[$old_key]); // a leftover of an earlier change, spelled differently } if ($new_key !== null) { unset($redirects[$new_key]); // the page lives here now: a 301 away from this address would hide it } unset($redirects[$new_path]); $redirects[$old_path] = $permalink; update_option('seolok_redirects', $redirects, true); seolok_purge_cached_urls(array($old_permalink, $permalink)); return array('applied' => true, 'postId' => (int) $post_id, 'previous' => $previous, 'newUrl' => $permalink, 'via' => 'core'); } /** * Undo of seolok_rename_slug(), bound to the page itself (its id and the marker the change left on it). The page gets its old * address back, SEOLOK's 301 from it goes and the 301s the change had replaced come back (never over a page that answers there * now, never as a loop). If the page already has its old address (put back by hand, or an earlier undo whose answer got lost), * only SEOLOK's own 301 is cleaned up, because it would keep sending visitors away from the live page. A page that has any other * address is left alone. * @return array|WP_Error */ function seolok_restore_slug($post_id, $current_url, $old_slug, $expected_slug, $previous_old, $previous_new) { $post = get_post($post_id); if (!$post) { return new WP_Error('seolok_post_not_found', 'This page does not exist any more', array('status' => 404)); } if (!seolok_valid_slug($old_slug) || !seolok_valid_slug($expected_slug) || $old_slug === $expected_slug || !seolok_same_host($current_url) || !empty(wp_parse_url($current_url, PHP_URL_QUERY))) { return new WP_Error('seolok_invalid', 'slug and expected must be two different plain slugs and url an address of this site without a query', array('status' => 400)); } $marker = seolok_find_rename_marker($post_id, $old_slug, $expected_slug); $current = (string) $post->post_name; $left_key = seolok_path_key($current_url); // the address SEOLOK gave the page: what SEOLOK's 301 points to if ($current === $expected_slug) { if ($marker === null) { return new WP_Error('seolok_changed_since', 'This page carries no such address change by SEOLOK (it may be undone already)', array('status' => 409)); } $blocker = seolok_rename_blocker($post, $post_id); if ($blocker !== null) { return seolok_rename_refused($blocker); } $permalink = (string) get_permalink($post_id); if (seolok_path_key($permalink) !== $left_key) { return new WP_Error('seolok_changed_since', 'This page is not at the address SEOLOK gave it', array('status' => 409)); } $other = url_to_postid(seolok_with_last_segment($permalink, $old_slug)); if (($other && (int) $other !== (int) $post_id) || wp_unique_post_slug($old_slug, $post_id, $post->post_status, $post->post_type, $post->post_parent) !== $old_slug) { return new WP_Error('seolok_slug_taken', 'Another page uses the old address now', array('status' => 409)); } $result = seolok_write_slug($post_id, $old_slug); if (is_wp_error($result) || !$result) { return is_wp_error($result) ? $result : new WP_Error('seolok_write_failed', 'The page could not be saved', array('status' => 500)); } if ((string) get_post_field('post_name', $post_id) !== $old_slug) { seolok_write_slug($post_id, $expected_slug); return new WP_Error('seolok_slug_taken', 'WordPress would not give this page its old address back', array('status' => 409)); } } elseif ($current !== $old_slug) { return new WP_Error('seolok_changed_since', 'The address of this page was changed after SEOLOK changed it', array('status' => 409)); } // The page has its old address now: put back just now, by hand, or by an earlier undo whose answer got lost (then only SEOLOK's own // 301 is left to clean up). A published page's addresses are spelled as WordPress spells them; a draft's plain permalink has no path. $published = $post->post_status === 'publish'; if ($published) { $now = (string) get_permalink($post_id); $old_path = seolok_normalize_path($now); $new_path = seolok_normalize_path(seolok_with_last_segment($now, $expected_slug)); $purge = array($now, seolok_with_last_segment($now, $expected_slug)); } else { $old_path = seolok_normalize_path(seolok_with_last_segment($current_url, $old_slug)); $new_path = seolok_normalize_path($current_url); $purge = array(seolok_with_last_segment($current_url, $old_slug), $current_url); } $redirects = seolok_get_redirects(); $old_key = seolok_find_redirect_key($redirects, $old_path); if ($old_key !== null && seolok_path_key($redirects[$old_key]) === $left_key) { unset($redirects[$old_key]); // SEOLOK's 301 away from the address the page has again } if ($marker !== null && $published) { // what the change had replaced comes back, except where a page answers now or it would close a circle foreach (array($old_path => $previous_old, $new_path => $previous_new) as $path => $url) { $url = esc_url_raw((string) $url); if ($path !== '/' && $url !== '' && seolok_same_host($url) && seolok_find_redirect_key($redirects, $path) === null && seolok_redirect_is_safe($redirects, $path, $url) && !seolok_path_has_page($path)) { $redirects[$path] = $url; } } } update_option('seolok_redirects', $redirects, true); seolok_drop_rename_marker($post_id, $old_slug, $expected_slug); delete_post_meta($post_id, '_wp_old_slug', $expected_slug); // WordPress' own redirect from the address that was just left seolok_purge_cached_urls($purge); return array('applied' => true, 'postId' => (int) $post_id, 'previous' => null, 'via' => 'core'); } /* ------------------------------------------------------------ REST routes */ function seolok_permission(WP_REST_Request $request) { $ok = seolok_check_signature( (string) seolok_option('token'), $request->get_header('x_seolok_timestamp'), (string) $request->get_header('x_seolok_signature'), $request->get_body(), time() ); return $ok ? true : new WP_Error('seolok_signature', 'Invalid or expired signature', array('status' => 401)); } function seolok_rest_ping() { return rest_ensure_response(array( 'plugin' => SEOLOK_VERSION, 'wordpress' => get_bloginfo('version'), 'seo' => seolok_detect_seo_plugin(), 'home' => home_url('/'), )); } function seolok_rest_apply(WP_REST_Request $request) { $data = json_decode($request->get_body(), true); if (!is_array($data) || empty($data['type']) || empty($data['url'])) { return new WP_Error('seolok_invalid', 'Invalid instruction', array('status' => 400)); } $type = (string) $data['type']; // Link indexing: store the site's public key; it is served as /.txt (see seolok_serve_indexing_key) if ($type === 'indexnow_key') { $key = isset($data['key']) ? (string) $data['key'] : ''; if (!seolok_valid_indexing_key($key)) { return new WP_Error('seolok_invalid', 'Invalid key', array('status' => 400)); } $previous = (string) seolok_option('indexnow_key'); update_option('seolok_indexnow_key', $key, true); return rest_ensure_response(array('applied' => true, 'postId' => null, 'previous' => array('indexnowKey' => $previous), 'via' => 'seolok')); } if ($type === 'redirect') { $target = isset($data['target']) ? esc_url_raw((string) $data['target']) : ''; if ($target === '' || !seolok_same_host($target) || !seolok_same_host((string) $data['url'])) { return new WP_Error('seolok_invalid_redirect', 'Redirect source and target must be on this site', array('status' => 422)); } $stored = seolok_store_redirect(seolok_normalize_path((string) $data['url']), $target, 'seolok'); if (is_wp_error($stored)) { return $stored; } return rest_ensure_response(array('applied' => true, 'postId' => null, 'previous' => array('redirect' => $stored['previous']), 'via' => 'seolok')); } // Undo of a redirect: only while the stored target is still the one SEOLOK set; then the previous target (if any) comes back. if ($type === 'remove_redirect') { if (!seolok_same_host((string) $data['url'])) { return new WP_Error('seolok_invalid_redirect', 'Redirect source must be on this site', array('status' => 422)); } $from = seolok_normalize_path((string) $data['url']); $expected = isset($data['expected']) ? esc_url_raw((string) $data['expected']) : ''; $redirects = get_option('seolok_redirects', array()); $redirects = is_array($redirects) ? $redirects : array(); if ($expected === '' || !isset($redirects[$from]) || $redirects[$from] !== $expected) { return new WP_Error('seolok_changed_since', 'This redirect is no longer the one SEOLOK added', array('status' => 409)); } $previous = isset($data['previous']) && is_string($data['previous']) && $data['previous'] !== '' ? esc_url_raw($data['previous']) : ''; unset($redirects[$from]); if ($previous !== '' && seolok_same_host($previous) && seolok_redirect_is_safe($redirects, $from, $previous)) { $redirects[$from] = $previous; // the one this undo replaced comes back, unless the 301s added since would turn it into a circle } update_option('seolok_redirects', $redirects, true); seolok_404_unredirected($from); return rest_ensure_response(array('applied' => true, 'postId' => null, 'previous' => array('redirect' => $expected), 'via' => 'seolok')); } // Undo of a page address change: found by the page's id (the old address is not served by the page any more, and the change // left a marker on that page); `url` = the address it has now, `slug` = the old slug, `expected` = the slug SEOLOK set if ($type === 'restore_slug') { $restore_id = isset($data['postId']) ? (int) $data['postId'] : 0; if ($restore_id <= 0) { return new WP_Error('seolok_invalid', 'postId is required', array('status' => 400)); } return rest_ensure_response(seolok_restore_slug($restore_id, (string) $data['url'], isset($data['slug']) ? (string) $data['slug'] : '', isset($data['expected']) ? (string) $data['expected'] : '', isset($data['previousOld']) ? $data['previousOld'] : '', isset($data['previousNew']) ? $data['previousNew'] : '')); } // Page address change (`url` = the page's address now, `target` = the address it should get). Asked again after it happened // (the answer may have got lost): the same answer, nothing is changed twice. $rename_target = ''; if ($type === 'rename_slug') { $rename_target = isset($data['target']) ? esc_url_raw((string) $data['target']) : ''; if ($rename_target === '') { return new WP_Error('seolok_invalid', 'target (the new address) is required', array('status' => 400)); } $done = seolok_completed_rename((string) $data['url'], $rename_target); if ($done !== null) { return rest_ensure_response($done); } } $resolved = seolok_resolve_target((string) $data['url']); if (is_wp_error($resolved)) { return $resolved; } $post_id = $resolved['kind'] === 'post' ? $resolved['id'] : null; if ($type === 'replace_link' || $type === 'unlink' || $type === 'restore_content') { if ($post_id === null) { return new WP_Error('seolok_unsupported_target', 'The posts index has no content of its own to edit', array('status' => 422)); } if ($type === 'restore_content') { $backup_id = isset($data['backupId']) ? (string) $data['backupId'] : ''; return $backup_id === '' ? new WP_Error('seolok_invalid', 'backupId is required', array('status' => 400)) : rest_ensure_response(seolok_restore_content($post_id, $backup_id)); } $from = isset($data['from']) ? (string) $data['from'] : ''; $to = isset($data['to']) ? esc_url_raw((string) $data['to']) : ''; if (seolok_link_key($from) === '' || ($type === 'replace_link' && !seolok_is_web_url($to))) { return new WP_Error('seolok_invalid', 'from (a page link) and, for replace_link, a http(s) to are required', array('status' => 400)); } $result = seolok_apply_link_edit($post_id, $type === 'replace_link' ? 'replace' : 'unlink', $from, $to); return is_wp_error($result) ? $result : rest_ensure_response($result); } if ($type === 'rename_slug') { if ($post_id === null) { return new WP_Error('seolok_unsupported_target', 'The posts index has no address of its own to change', array('status' => 422)); } return rest_ensure_response(seolok_rename_slug($post_id, (string) $data['url'], $rename_target)); } if ($type === 'restore_meta') { $title = isset($data['title']) ? sanitize_text_field((string) $data['title']) : ''; $desc = isset($data['metaDescription']) ? sanitize_text_field((string) $data['metaDescription']) : ''; $previous = array('title' => seolok_get_value($resolved, 'title'), 'metaDescription' => seolok_get_value($resolved, 'metadesc')); seolok_restore_value($resolved, 'title', $title); seolok_restore_value($resolved, 'metadesc', $desc); return rest_ensure_response(array('applied' => true, 'postId' => $post_id, 'previous' => $previous, 'via' => seolok_detect_seo_plugin() ?: 'core')); } if ($type === 'set_meta') { $title = isset($data['title']) ? sanitize_text_field((string) $data['title']) : ''; $desc = isset($data['metaDescription']) ? sanitize_text_field((string) $data['metaDescription']) : ''; if ($title === '' || $desc === '') { return new WP_Error('seolok_invalid', 'title and metaDescription are required', array('status' => 400)); } // the suggestion names the title it was written for: a title someone changed since is not overwritten in silence (SEOLOK writes the text again first) if (isset($data['expectedTitle']) && seolok_title_changed_since($resolved, (string) $data['expectedTitle'])) { return new WP_Error('seolok_changed_since', 'The page\'s title changed after this suggestion was written', array('status' => 409, 'currentTitle' => seolok_effective_title($resolved))); } $previous = array('title' => seolok_get_value($resolved, 'title'), 'metaDescription' => seolok_get_value($resolved, 'metadesc')); seolok_set_value($resolved, 'title', $title); seolok_set_value($resolved, 'metadesc', $desc); return rest_ensure_response(array('applied' => true, 'postId' => $post_id, 'previous' => $previous, 'via' => seolok_detect_seo_plugin() ?: 'core')); } if ($type === 'noindex') { $previous = array('noindex' => seolok_get_value($resolved, 'noindex')); seolok_set_value($resolved, 'noindex', '1'); return rest_ensure_response(array('applied' => true, 'postId' => $post_id, 'previous' => $previous, 'via' => seolok_detect_seo_plugin() ?: 'core')); } return new WP_Error('seolok_unsupported', 'Unsupported instruction type', array('status' => 422)); } add_action('rest_api_init', function () { register_rest_route('seolok/v1', '/ping', array('methods' => 'POST', 'callback' => 'seolok_rest_ping', 'permission_callback' => 'seolok_permission')); register_rest_route('seolok/v1', '/apply', array('methods' => 'POST', 'callback' => 'seolok_rest_apply', 'permission_callback' => 'seolok_permission')); }); /* ------------------------------------------------- front-end: meta, robots */ add_filter('pre_get_document_title', function ($title) { if (seolok_detect_seo_plugin()) { return $title; } $custom = seolok_current_value('title'); return $custom !== '' ? $custom : $title; }, 20); add_action('wp_head', function () { if (seolok_detect_seo_plugin()) { return; } $desc = seolok_current_value('metadesc'); if ($desc !== '') { echo '' . "\n"; } }, 1); // Yoast SEO add_filter('wpseo_title', function ($title) { $custom = seolok_current_value('title'); return $custom !== '' ? $custom : $title; }, 20); add_filter('wpseo_metadesc', function ($desc) { $custom = seolok_current_value('metadesc'); return $custom !== '' ? $custom : $desc; }, 20); add_filter('wpseo_robots', function ($robots) { return seolok_current_value('noindex') === '1' ? 'noindex, follow' : $robots; }, 20); // Rank Math add_filter('rank_math/frontend/title', function ($title) { $custom = seolok_current_value('title'); return $custom !== '' ? $custom : $title; }, 20); add_filter('rank_math/frontend/description', function ($desc) { $custom = seolok_current_value('metadesc'); return $custom !== '' ? $custom : $desc; }, 20); add_filter('rank_math/frontend/robots', function ($robots) { if (seolok_current_value('noindex') === '1') { $robots['index'] = 'noindex'; } return $robots; }, 20); // Core robots (WordPress 5.7+) add_filter('wp_robots', function ($robots) { if (seolok_current_value('noindex') === '1') { $robots['noindex'] = true; $robots['follow'] = true; unset($robots['index']); } return $robots; }, 20); // 301 redirects created by merge_redirect actions add_action('template_redirect', function () { $redirects = get_option('seolok_redirects', array()); if (!is_array($redirects) || empty($redirects)) { return; } // the request path as received (it already carries a sub-folder install's folder; home_url() would add it a second time) // only compared with the stored redirect keys, never printed or stored; sanitize_text_field() would drop %-encoded letters of non-ASCII addresses $request_uri = isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '/'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $key = seolok_find_redirect_key($redirects, $request_uri); // never a redirect to where the visitor already is (a 301 whose target is its own key, or the very path asked for) if ($key !== null && seolok_normalize_path($redirects[$key]) !== $key && seolok_normalize_path($redirects[$key]) !== seolok_normalize_path('/' . ltrim((string) $request_uri, '/'))) { wp_safe_redirect($redirects[$key], 301, 'SEOLOK'); exit; } }, 1); /* ------------------------------------------------------ 404 addresses (0.6.7) */ /** * Addresses visitors asked for that do not exist (WordPress answered 404), counted per path: the path only (no query string, no * IP address, no browser, no time of day; the first and last day are kept). Where the visitor came from (0.6.8): a page of this * site is kept by its path (that is where the dead link sits), another site by its domain only (google.com), none = typed or a * bookmark; at most SEOLOK_404_SOURCES of each per address. Not counted: logged-in staff, requests * other than GET/HEAD, WordPress' own and scanner addresses (wp-admin, wp-login, xmlrpc, any .php, dot files) and files (.js, .css, * images, fonts, source maps, archives). At most SEOLOK_404_MAX paths are kept: a new one pushes out the least-asked one. With live * sync on, the most-asked SEOLOK_404_REPORT go to SEOLOK with the hourly report; SEOLOK suggests a page for a 301. */ define('SEOLOK_404_MAX', 500); define('SEOLOK_404_REPORT', 30); define('SEOLOK_404_IGNORED_MAX', 200); define('SEOLOK_404_DONE_MAX', 50); define('SEOLOK_404_PATH_MAX', 255); define('SEOLOK_404_SOURCES', 5); /** The path of a request as it would be stored ("/eski-sayfa"), or '' when it is not one (empty, too long, not plain printable ASCII: browsers send %-codes). */ function seolok_404_path($request_uri) { $path = (string) $request_uri; $path = explode('#', explode('?', $path, 2)[0], 2)[0]; $path = '/' . trim($path, '/'); if ($path === '/' || strlen($path) > SEOLOK_404_PATH_MAX || preg_match('#^/[\x21-\x7E]*$#D', $path) !== 1) { return ''; } return $path; } /** Requests that are not a lost visitor: WordPress' own addresses, scanners looking for weak spots, and files. */ function seolok_404_noise($path) { $p = strtolower(rawurldecode((string) $path)); if (preg_match('#^/(wp-admin|wp-includes|wp-json|wp-login|wp-signup|wp-cron|xmlrpc|cgi-bin|phpmyadmin)(/|\.|$)#', $p)) { return true; } if (preg_match('#\.php[0-9]?(/|$)#', $p) || strpos($p, '/.') !== false) { return true; // /x.php, /x.php/y, /.env, /.git/config } return preg_match('#\.(js|mjs|css|map|png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?|woff2?|ttf|otf|eot|zip|gz|tgz|tar|rar|7z|sql|bak|old|swp|log|ini|env|ya?ml|cfg|conf)$#', $p) === 1; } function seolok_404_get($name) { $value = get_option($name, array()); return is_array($value) ? $value : array(); } /** path => array(c: hits, f: first day, l: last day) */ function seolok_404_log() { return seolok_404_get('seolok_404_log'); } /** * Where a request came from, from its Referer: array('page' => path) for a page of this site, array('host' => domain) for another * site, array() for none (typed, a bookmark, an app). Nothing else of the address is kept. */ function seolok_404_source($referer) { $ref = (string) $referer; $host = $ref !== '' ? strtolower((string) wp_parse_url($ref, PHP_URL_HOST)) : ''; if ($host === '' || !preg_match('/^[a-z0-9.-]{1,253}$/', $host)) { return array(); } $own = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); $bare = function ($h) { return preg_replace('/^www\./', '', $h); }; if ($bare($host) === $bare($own)) { $path = seolok_404_path((string) wp_parse_url($ref, PHP_URL_PATH)); return array('page' => $path === '' ? '/' : $path); } return array('host' => $bare($host)); } /** Adds one to `$map[$key]`, keeping the `$max` most frequent keys. */ function seolok_404_bump($map, $key, $max) { $map = is_array($map) ? $map : array(); $map[$key] = (isset($map[$key]) ? (int) $map[$key] : 0) + 1; arsort($map); return array_slice($map, 0, $max, true); } function seolok_404_count($path, $now, $source = array()) { $day = gmdate('Y-m-d', (int) $now); $log = seolok_404_log(); if (isset($log[$path]) && is_array($log[$path])) { $log[$path]['c'] = min(1000000000, (int) (isset($log[$path]['c']) ? $log[$path]['c'] : 0) + 1); $log[$path]['l'] = $day; $log[$path] = seolok_404_note_source($log[$path], $source); update_option('seolok_404_log', $log, false); return; } else { while (count($log) >= SEOLOK_404_MAX) { // full: the least-asked path makes room (of those, the one not seen for longest), so a scanner's one-off paths never push out real ones $drop = null; foreach ($log as $key => $entry) { $c = is_array($entry) && isset($entry['c']) ? (int) $entry['c'] : 0; $l = is_array($entry) && isset($entry['l']) ? (string) $entry['l'] : ''; if ($drop === null || $c < $drop[1] || ($c === $drop[1] && strcmp($l, $drop[2]) < 0)) { $drop = array($key, $c, $l); } } unset($log[$drop[0]]); } $log[$path] = seolok_404_note_source(array('c' => 1, 'f' => $day, 'l' => $day), $source); } update_option('seolok_404_log', $log, false); } /** p: own pages that sent visitors (path => n), h: other sites (domain => n), d: requests without a source. */ function seolok_404_note_source($entry, $source) { if (isset($source['page'])) { $entry['p'] = seolok_404_bump(isset($entry['p']) ? $entry['p'] : array(), (string) $source['page'], SEOLOK_404_SOURCES); } elseif (isset($source['host'])) { $entry['h'] = seolok_404_bump(isset($entry['h']) ? $entry['h'] : array(), (string) $source['host'], SEOLOK_404_SOURCES); } else { $entry['d'] = (isset($entry['d']) ? (int) $entry['d'] : 0) + 1; } return $entry; } /** Counts this request when WordPress answers it with "not found" (after every redirect had its turn). */ function seolok_404_track() { if (!function_exists('is_404') || !is_404()) { return; } if (function_exists('is_user_logged_in') && is_user_logged_in() && current_user_can('edit_posts')) { return; // the site's own people trying addresses are not lost visitors } $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper(sanitize_key(wp_unslash($_SERVER['REQUEST_METHOD']))) : 'GET'; if ($method !== 'GET' && $method !== 'HEAD') { return; } // only compared and stored as a path (query string dropped); sanitize_text_field() would drop %-encoded letters of non-ASCII addresses $path = seolok_404_path(isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : ''); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ($path === '' || seolok_404_noise($path)) { return; } $ignored = seolok_404_get('seolok_404_ignored'); if (isset($ignored[$path])) { return; } $referer = isset($_SERVER['HTTP_REFERER']) ? substr(wp_unslash($_SERVER['HTTP_REFERER']), 0, 2000) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only its host and path are read, see seolok_404_source() seolok_404_count($path, time(), seolok_404_source($referer)); } add_action('template_redirect', 'seolok_404_track', 99); /** The most-asked paths, for the report and the screen: array of {path, hits, first, last}, most hits first, then the latest seen. */ function seolok_404_top($limit) { $rows = array(); foreach (seolok_404_log() as $path => $entry) { if (!is_array($entry)) { continue; } $rows[] = array('path' => (string) $path, 'hits' => (int) (isset($entry['c']) ? $entry['c'] : 0), 'first' => (string) (isset($entry['f']) ? $entry['f'] : ''), 'last' => (string) (isset($entry['l']) ? $entry['l'] : ''), 'pages' => isset($entry['p']) && is_array($entry['p']) ? $entry['p'] : array(), 'hosts' => isset($entry['h']) && is_array($entry['h']) ? $entry['h'] : array(), 'direct' => isset($entry['d']) ? (int) $entry['d'] : 0); } usort($rows, function ($a, $b) { if ($a['hits'] !== $b['hits']) { return $b['hits'] - $a['hits']; } $by_day = strcmp($b['last'], $a['last']); return $by_day !== 0 ? $by_day : strcmp($a['path'], $b['path']); }); return array_slice($rows, 0, max(0, (int) $limit)); } /** A 301 was stored for `$from` (see seolok_store_redirect): a 404 address leaves the list and is kept under "Redirected" (newest first). */ function seolok_404_redirected($from, $target, $by) { $log = seolok_404_log(); if (!isset($log[$from])) { return; } $entry = is_array($log[$from]) ? $log[$from] : array(); unset($log[$from]); update_option('seolok_404_log', $log, false); $done = seolok_404_get('seolok_404_done'); unset($done[$from]); $done = array($from => array('to' => (string) $target, 'at' => time(), 'by' => $by === 'seolok' ? 'seolok' : 'wp', 'c' => (int) (isset($entry['c']) ? $entry['c'] : 0))) + $done; update_option('seolok_404_done', array_slice($done, 0, SEOLOK_404_DONE_MAX, true), false); seolok_purge_cached_urls(array(seolok_path_url($from))); // a page cache keeps the old "not found" otherwise } /** The 301 of `$from` was taken back: it is no longer listed as redirected (it is counted again if visitors keep asking for it). */ function seolok_404_unredirected($from) { $done = seolok_404_get('seolok_404_done'); if (isset($done[$from])) { unset($done[$from]); update_option('seolok_404_done', $done, false); } } /* ------------------------------------------------------ Link indexing */ function seolok_valid_indexing_key($key) { return is_string($key) && preg_match('/^[a-zA-Z0-9-]{8,128}$/', $key) === 1; } /** Serves the key file at the site root (/.txt, content = key) so search engines can verify SEOLOK's notifications. */ function seolok_serve_indexing_key() { $key = (string) seolok_option('indexnow_key'); if (!seolok_valid_indexing_key($key)) { return; } $path = isset($_SERVER['REQUEST_URI']) ? wp_parse_url(wp_unslash($_SERVER['REQUEST_URI']), PHP_URL_PATH) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared with one fixed path $base = wp_parse_url(home_url('/'), PHP_URL_PATH); $expected = rtrim(is_string($base) ? $base : '/', '/') . '/' . $key . '.txt'; if ($path !== $expected && $path !== '/' . $key . '.txt') { return; } status_header(200); header('Content-Type: text/plain; charset=utf-8'); header('Cache-Control: public, max-age=3600'); header('X-Robots-Tag: noindex'); echo esc_html($key); // [a-zA-Z0-9-] only (checked above): unchanged by escaping exit; } add_action('init', 'seolok_serve_indexing_key', 0); /* --------------------------------------------------------- Web Push */ /** Signed GET to SEOLOK for the push config (enabled only while a Boost campaign runs). Cached 1 hour. */ function seolok_push_config() { $cached = get_transient('seolok_push_config'); if (is_array($cached)) { return $cached; } $site = (string) seolok_option('site_id'); $token = (string) seolok_option('token'); $config = array('enabled' => false, 'vapidPublicKey' => '', 'network' => false); if ($site !== '' && $token !== '') { $ts = time(); $response = wp_remote_get(seolok_api_base() . '/api/wp/config', array( 'timeout' => 5, 'headers' => array('X-Seolok-Site' => $site, 'X-Seolok-Timestamp' => (string) $ts, 'X-Seolok-Signature' => seolok_signature($token, $ts, $site)), )); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { $body = json_decode(wp_remote_retrieve_body($response), true); if (isset($body['push']) && is_array($body['push'])) { $config = array('enabled' => !empty($body['push']['enabled']), 'vapidPublicKey' => (string) ($body['push']['vapidPublicKey'] ?? ''), 'network' => !empty($body['network']['enabled'])); } } } set_transient('seolok_push_config', $config, HOUR_IN_SECONDS); return $config; } /** The visitor-facing features (Boost notification button, network box) are off until an administrator ticks them in Settings → SEOLOK. */ function seolok_visitor_features() { return seolok_is_configured() && get_option('seolok_visitor_features') === '1'; } function seolok_sw_path() { $base = wp_parse_url(home_url('/'), PHP_URL_PATH); return rtrim(is_string($base) ? $base : '/', '/') . '/seolok-sw.js'; } // Service worker served from the site root (scope "/") without touching rewrite rules. add_action('init', function () { $path = isset($_SERVER['REQUEST_URI']) ? wp_parse_url(wp_unslash($_SERVER['REQUEST_URI']), PHP_URL_PATH) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared with one fixed path if ($path !== seolok_sw_path()) { return; } header('Content-Type: application/javascript; charset=utf-8'); header('Service-Worker-Allowed: /'); header('Cache-Control: public, max-age=3600'); echo "self.addEventListener('push',function(e){var d={};try{d=e.data?e.data.json():{};}catch(x){}e.waitUntil(self.registration.showNotification(d.title||'',{body:d.body||'',icon:d.icon,data:{url:d.url||'/'}}));});\n"; echo "self.addEventListener('notificationclick',function(e){e.notification.close();e.waitUntil(clients.openWindow((e.notification.data&&e.notification.data.url)||'/'));});\n"; exit; }, 0); add_action('wp_enqueue_scripts', function () { if (is_admin() || !seolok_visitor_features()) { return; } $config = seolok_push_config(); if (empty($config['enabled']) || $config['vapidPublicKey'] === '') { return; } wp_enqueue_script('seolok-push', plugins_url('assets/seolok-push.js', __FILE__), array(), SEOLOK_VERSION, true); wp_localize_script('seolok-push', 'SEOLOK_PUSH', array( 'api' => seolok_api_base(), 'siteId' => (string) seolok_option('site_id'), 'vapidKey' => $config['vapidPublicKey'], 'swUrl' => home_url('/seolok-sw.js'), 'labels' => array('button' => seolok_t('push_button'), 'dismiss' => seolok_t('push_dismiss'), 'thanks' => seolok_t('push_thanks')), )); }); /* ------------------------------------------------ SEOLOK network widget */ // Recommendations from other member sites (nofollow sponsored) under single posts, loaded client-side. add_filter('the_content', function ($content) { if (!is_singular('post') || !in_the_loop() || !is_main_query() || !seolok_visitor_features()) { return $content; } $config = seolok_push_config(); if (empty($config['network'])) { return $content; } return $content . '
'; }, 99); add_action('wp_enqueue_scripts', function () { if (!is_singular('post') || !seolok_visitor_features()) { return; } $config = seolok_push_config(); if (empty($config['network'])) { return; } wp_enqueue_script('seolok-network', plugins_url('assets/seolok-network.js', __FILE__), array(), SEOLOK_VERSION, true); wp_localize_script('seolok-network', 'SEOLOK_NETWORK', array('api' => seolok_api_base(), 'siteId' => (string) seolok_option('site_id'))); }); /* ------------------------------------------------------------ Live sync (opt-in) */ /** * Off until the site's owner switches it on (SEOLOK → Lokman → "Live sync", an administrator's click). While on, the plugin tells * SEOLOK - signed like every other call - about its site: * - now and then (hourly through WP-Cron, and whenever Lokman is opened after a while) a snapshot: WordPress/PHP version, the site * address and language, whether search engines are allowed in, the SEO/cache plugin in use, post/page/draft counts, how many * updates wait, how late WP-Cron is; * - as they happen, events: a page published/updated/unpublished/deleted (address + title), a page address changed, search engines * switched on/off, the permalink structure changed, a plugin or theme switched. * Never sent: page content, visitors, users, comments, passwords. Events wait in an option (the outbox) until SEOLOK confirmed them; * each has an id, so a resent one is stored once. Switching it off sends one last word and clears the schedule and the outbox. */ define('SEOLOK_SYNC_MAX_EVENTS', 100); define('SEOLOK_SYNC_BATCH', 50); define('SEOLOK_SYNC_BATCH_BYTES', 40000); // a report stays far below SEOLOK's 64 KB limit, whatever the titles are made of define('SEOLOK_SYNC_EVENT_TTL', 7 * 86400); define('SEOLOK_SYNC_BEAT_HOOK', 'seolok_cron_beat'); define('SEOLOK_SYNC_FLUSH_HOOK', 'seolok_cron_flush'); define('SEOLOK_SYNC_BACKOFF_BASE', 60); // seconds before the second try after a failed report; doubles each time define('SEOLOK_SYNC_BACKOFF_MAX', 3600); function seolok_sync_enabled() { return seolok_is_configured() && get_option('seolok_sync') === '1'; } /** * The outbox as it is in the database NOW. A request that waited for SEOLOK (seconds) must not work from the copy WordPress cached * when it first read the option: an event another request queued meanwhile would be overwritten and lost. */ function seolok_sync_outbox() { wp_cache_delete('seolok_outbox', 'options'); $missing = wp_cache_get('notoptions', 'options'); if (is_array($missing) && isset($missing['seolok_outbox'])) { unset($missing['seolok_outbox']); wp_cache_set('notoptions', $missing, 'options'); } $box = get_option('seolok_outbox', array()); return is_array($box) ? $box : array(); } /** Short text for an event: tags removed, cut to $max characters. */ function seolok_sync_text($value, $max) { $text = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags((string) $value))); return function_exists('mb_substr') ? mb_substr($text, 0, $max) : substr($text, 0, $max); } /** * Puts an event in the outbox and asks WP-Cron to send it in a moment. $key joins repeats: a page saved ten times in a minute * is one "updated" event (the newest time wins). */ function seolok_sync_queue($type, $data, $key = '') { if (!seolok_sync_enabled()) { return; } $now = time(); $box = array_values(array_filter(seolok_sync_outbox(), function ($event) use ($now) { return is_array($event) && isset($event['id'], $event['at']) && $event['at'] > $now - SEOLOK_SYNC_EVENT_TTL; })); $joined = false; if ($key !== '') { foreach ($box as $i => $event) { if (isset($event['key']) && $event['key'] === $key) { $box[$i]['at'] = $now; $box[$i]['data'] = $data; $joined = true; break; } } } if (!$joined) { $box[] = array('id' => wp_generate_uuid4(), 'type' => $type, 'at' => $now, 'data' => $data, 'key' => $key); } update_option('seolok_outbox', array_slice($box, -SEOLOK_SYNC_MAX_EVENTS), false); seolok_sync_schedule_flush(); } /** * The address and title of a page (never its content). A page that was just taken offline is asked for its address as if it were * still published (a draft's address would be the plain "?p=12"), and a trashed page's address loses WordPress' "__trashed" suffix. */ function seolok_sync_page_data($post, $as_published = false) { $view = $post; if ($as_published) { $view = clone $post; $view->post_status = 'publish'; } $url = get_permalink($view); $url = is_string($url) ? str_replace('__trashed', '', $url) : ''; return array( 'url' => strlen($url) <= 2000 ? $url : '', // longer than SEOLOK takes: not reported 'title' => seolok_sync_text(isset($post->post_title) ? $post->post_title : '', 200), 'kind' => substr(sanitize_key(isset($post->post_type) ? $post->post_type : 'post'), 0, 24), ); } /** * A page of the public site: a post type visitors can open (not an attachment, menu item, template or other internal type), and * not one that is made of what visitors write (forum topics and replies) or of builder templates. Sites can change the list. */ function seolok_sync_is_page($post) { if (!is_object($post) || empty($post->post_type) || !seolok_sync_type_allowed($post->post_type)) { return false; } $picked = get_option('seolok_sync_types', null); if (is_array($picked) && !in_array($post->post_type, $picked, true)) { return false; // the owner left this type out (SEOLOK → Live sync → Content types) } return true; } /** * A post type the live sync may ever tell about: public and openable by visitors, not WordPress' own internals (media, menus, templates, * patterns), not builder templates, forms or form entries (public in name only: `exclude_from_search`, not queryable), not what visitors * write (forum topics and replies). The same rule draws the list on the Live sync screen, so what the screen offers is what is sent. */ function seolok_sync_type_allowed($name) { $internal = array('attachment', 'revision', 'nav_menu_item', 'customize_changeset', 'oembed_cache', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face'); if (!is_string($name) || $name === '' || in_array($name, $internal, true)) { return false; } $skipped = apply_filters('seolok_sync_skipped_post_types', array('topic', 'reply', 'elementor_library', 'elementor-hf', 'fl-builder-template', 'ct_template', 'oxy_user_library', 'wpcf7_contact_form', 'flamingo_inbound', 'wpforms', 'frm_form', 'nf_sub', 'acf-field-group', 'scheduled-action', 'shop_order', 'shop_coupon')); if (is_array($skipped) && in_array($name, $skipped, true)) { return false; } if (!is_post_type_viewable($name)) { return false; } $type = function_exists('get_post_type_object') ? get_post_type_object($name) : null; if (is_object($type) && ((isset($type->exclude_from_search) && $type->exclude_from_search) || (isset($type->publicly_queryable) && $type->publicly_queryable === false))) { return false; } return true; } /** The status shows the page to visitors ("publish", or a custom public one such as a closed forum topic). */ function seolok_sync_status_live($status) { return $status === 'publish' || is_post_status_viewable($status); } add_action('transition_post_status', function ($new, $old, $post) { if (!seolok_sync_enabled() || !seolok_sync_is_page($post) || wp_is_post_revision($post) || wp_is_post_autosave($post)) { return; } $is = seolok_sync_status_live($new); $was = seolok_sync_status_live($old); if ($is) { $type = $was ? 'post_updated' : 'post_published'; } elseif ($was) { $type = 'post_unpublished'; // draft, private, pending or the trash } else { return; } $data = seolok_sync_page_data($post, $type === 'post_unpublished'); if ($data['url'] !== '') { seolok_sync_queue($type, $data, $type === 'post_updated' ? $type . '|' . $data['url'] : ''); } }, 10, 3); // a published page got a new address (its slug changed, by hand or by SEOLOK's "fix the page address"): the old address is news too add_action('post_updated', function ($post_id, $after, $before) { if (!seolok_sync_enabled() || !is_object($after) || !is_object($before) || !seolok_sync_is_page($after) || wp_is_post_revision($after)) { return; } if (!seolok_sync_status_live($after->post_status) || !seolok_sync_status_live($before->post_status)) { return; // published or taken offline: told as such by transition_post_status } $from = get_permalink($before); $to = get_permalink($after); if (!is_string($from) || !is_string($to) || $from === $to || strlen($from) > 2000 || strlen($to) > 2000) { return; } seolok_sync_queue('url_changed', array('from' => $from, 'to' => $to, 'title' => seolok_sync_text(isset($after->post_title) ? $after->post_title : '', 200))); }, 10, 3); // a published page deleted for good (one that went through the trash was told as "unpublished" then) add_action('before_delete_post', function ($post_id, $post = null) { if (!seolok_sync_enabled()) { return; } $post = is_object($post) ? $post : get_post($post_id); if (!seolok_sync_is_page($post) || !seolok_sync_status_live($post->post_status)) { return; } $data = seolok_sync_page_data($post); if ($data['url'] !== '') { seolok_sync_queue('post_deleted', $data); } }, 10, 2); add_action('update_option_blog_public', function ($old, $new) { if (!seolok_sync_enabled()) { return; } seolok_sync_queue('search_visibility', array('visible' => (string) $new !== '0')); seolok_lokman_forget(); // the cached Lokman page still says what it said before add_action('shutdown', 'seolok_sync_flush_now'); // this one is not worth waiting for WP-Cron }, 10, 2); add_action('update_option_permalink_structure', function ($old, $new) { seolok_sync_queue('permalinks_changed', array('structure' => seolok_sync_text($new, 100))); }, 10, 2); function seolok_sync_plugin_event($type, $plugin) { $dir = dirname((string) $plugin); $slug = substr(sanitize_key($dir !== '.' ? $dir : basename((string) $plugin, '.php')), 0, 60); if ($slug !== '' && $slug !== 'seolok') { seolok_sync_queue($type, array('slug' => $slug)); } } add_action('activated_plugin', function ($plugin) { seolok_sync_plugin_event('plugin_activated', $plugin); }); add_action('deactivated_plugin', function ($plugin) { seolok_sync_plugin_event('plugin_deactivated', $plugin); }); add_action('switch_theme', function ($name, $theme = null) { if (is_object($theme) && method_exists($theme, 'get_stylesheet')) { seolok_sync_queue('theme_switched', array('theme' => substr(sanitize_key($theme->get_stylesheet()), 0, 60))); } }, 10, 2); /** The cache plugin in use (by what it defines), or null. */ function seolok_detect_cache_plugin() { if (defined('LSCWP_V')) { return 'litespeed-cache'; } if (defined('WP_ROCKET_VERSION')) { return 'wp-rocket'; } if (defined('W3TC')) { return 'w3-total-cache'; } if (function_exists('wpsc_delete_url_cache')) { return 'wp-super-cache'; } if (defined('WPFC_MAIN_PATH')) { return 'wp-fastest-cache'; } return null; } /** Minutes the oldest scheduled WP-Cron job is overdue (0 = on time or nothing scheduled). A cron that does not run shows up here. */ function seolok_cron_overdue_minutes() { $cron = function_exists('_get_cron_array') ? _get_cron_array() : array(); if (!is_array($cron) || !$cron) { return 0; } $oldest = min(array_map('intval', array_keys($cron))); return $oldest > 0 && $oldest < time() ? (int) min(1000000, floor((time() - $oldest) / 60)) : 0; } /** How many updates wait: array(core => bool, plugins => n, themes => n), or null when WordPress has not looked yet. */ function seolok_pending_updates() { $core = get_site_transient('update_core'); $plugins = get_site_transient('update_plugins'); $themes = get_site_transient('update_themes'); if (!is_object($core) && !is_object($plugins) && !is_object($themes)) { return null; } $upgrade = false; if (is_object($core) && !empty($core->updates) && is_array($core->updates)) { $first = reset($core->updates); $upgrade = is_object($first) && isset($first->response) && $first->response === 'upgrade'; } return array( 'core' => $upgrade, 'plugins' => is_object($plugins) && !empty($plugins->response) && is_array($plugins->response) ? min(999, count($plugins->response)) : 0, 'themes' => is_object($themes) && !empty($themes->response) && is_array($themes->response) ? min(999, count($themes->response)) : 0, ); } function seolok_post_count($type, $status) { $counts = wp_count_posts($type); return is_object($counts) && isset($counts->$status) ? (int) $counts->$status : 0; } /** What the "report" says about the site. Numbers, versions and switches only (and, 0.6.7, the paths that answered 404). */ function seolok_sync_snapshot() { $scheme = wp_parse_url(home_url('/'), PHP_URL_SCHEME); return array( 'wp' => substr((string) get_bloginfo('version'), 0, 16), 'php' => PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, 'plugin' => SEOLOK_VERSION, 'locale' => substr((string) get_locale(), 0, 16), 'home' => substr(home_url('/'), 0, 300), 'https' => $scheme === 'https', 'multisite' => (bool) is_multisite(), 'searchVisible' => (string) get_option('blog_public', '1') !== '0', 'permalinks' => (string) get_option('permalink_structure', '') !== '', 'seoPlugin' => seolok_detect_seo_plugin(), 'cachePlugin' => seolok_detect_cache_plugin(), 'cron' => array('disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON, 'overdueMinutes' => seolok_cron_overdue_minutes()), 'queue' => seolok_sync_queue_state(), 'updates' => seolok_pending_updates(), 'counts' => array('posts' => seolok_post_count('post', 'publish'), 'pages' => seolok_post_count('page', 'publish'), 'drafts' => seolok_post_count('post', 'draft') + seolok_post_count('page', 'draft')), // 0.6.7: the most-asked addresses that answered 404 (path, number of hits, first and last day; nothing about who asked) // the address, its count and days only: where visitors came from stays in WordPress 'notFound' => array_map(function ($r) { return array('path' => $r['path'], 'hits' => $r['hits'], 'first' => $r['first'], 'last' => $r['last']); }, seolok_404_top(SEOLOK_404_REPORT)), ); } /** * What waits in the outbox, for the report and the screens: how many, how old the oldest is, when the next try is due and the last * error (its code only; no address, no text). `$leaving`: ids of the events this very report carries (they are not "waiting"). */ function seolok_sync_queue_state($leaving = array()) { $oldest = 0; $waiting = 0; foreach (seolok_sync_outbox() as $event) { if (!is_array($event) || !isset($event['id']) || isset($leaving[(string) $event['id']])) { continue; } $waiting++; $at = isset($event['at']) ? (int) $event['at'] : 0; if ($at > 0 && ($oldest === 0 || $at < $oldest)) { $oldest = $at; } } $next = wp_next_scheduled(SEOLOK_SYNC_FLUSH_HOOK); if ($next === false) { $next = wp_next_scheduled(SEOLOK_SYNC_BEAT_HOOK); } $stats = get_option('seolok_sync_stats', array()); $error = is_array($stats) && !empty($stats['error']) && is_array($stats['error']) ? $stats['error'] : null; return array( 'waiting' => $waiting, 'oldestAt' => $oldest, 'nextAt' => $next === false ? 0 : (int) $next, 'backoffUntil' => seolok_sync_backoff_until(), 'lastErrorCode' => $error && !empty($error['code']) ? substr(sanitize_key((string) $error['code']), 0, 40) : null, 'lastErrorAt' => $error && !empty($error['at']) ? (int) $error['at'] : 0, ); } /** Growing wait after a failed report (1, 2, 4 … minutes, at most an hour), cleared by a report that got through. Stored as array(n, until). */ function seolok_sync_backoff_until() { $b = get_option('seolok_sync_backoff', array()); return is_array($b) && isset($b['until']) ? (int) $b['until'] : 0; } function seolok_sync_backoff_bump() { $b = get_option('seolok_sync_backoff', array()); $n = is_array($b) && isset($b['n']) ? (int) $b['n'] : 0; $until = time() + (int) min(SEOLOK_SYNC_BACKOFF_MAX, SEOLOK_SYNC_BACKOFF_BASE * (2 ** min($n, 10))); update_option('seolok_sync_backoff', array('n' => $n + 1, 'until' => $until), false); // the next try is the backoff's moment, not "in 20 seconds" again wp_clear_scheduled_hook(SEOLOK_SYNC_FLUSH_HOOK); wp_schedule_single_event($until, SEOLOK_SYNC_FLUSH_HOOK); return $until; } function seolok_sync_backoff_clear() { delete_option('seolok_sync_backoff'); } /** The flush WP-Cron should have run by now has not run: WP-Cron is late or off. */ function seolok_sync_flush_overdue() { $next = wp_next_scheduled(SEOLOK_SYNC_FLUSH_HOOK); return $next !== false && $next < time() - MINUTE_IN_SECONDS; } /** * One report to SEOLOK: the snapshot and what waits in the outbox. Events SEOLOK took in (or already had) leave the outbox; the * ones that arrived meanwhile stay. After a failed report the next tries wait longer each time (backoff); `$force` is a person's own * press ("Send now", switching on), which does not wait. @return array|WP_Error SEOLOK's answer */ function seolok_sync_beat($timeout = 12, $force = false) { if (!seolok_sync_enabled()) { return new WP_Error('seolok_sync_off', 'Live sync is off'); } if (!$force && seolok_sync_backoff_until() > time()) { return new WP_Error('seolok_sync_backoff', 'Waiting before the next try'); } update_option('seolok_sync_try', time(), false); $events = array(); $sent = array(); $bytes = 0; foreach (array_slice(seolok_sync_outbox(), 0, SEOLOK_SYNC_BATCH) as $event) { $entry = array('id' => (string) $event['id'], 'type' => (string) $event['type'], 'at' => (int) $event['at'], 'data' => isset($event['data']) && is_array($event['data']) ? $event['data'] : new stdClass()); $bytes += strlen((string) wp_json_encode($entry)) + 1; if ($events && $bytes > SEOLOK_SYNC_BATCH_BYTES) { break; // the rest goes in the next report; one report that SEOLOK refuses as too large would block the outbox for good } $events[] = $entry; $sent[$entry['id']] = true; } $snapshot = seolok_sync_snapshot(); $snapshot['queue'] = seolok_sync_queue_state($sent); // what still waits once this report is in $answer = seolok_remote('POST', '/api/wp/ingest', array('v' => 1, 'snapshot' => $snapshot, 'events' => $events), $timeout); if (is_wp_error($answer)) { seolok_sync_note_error($answer); seolok_sync_backoff_bump(); return $answer; } seolok_sync_backoff_clear(); seolok_sync_note_sent($events); update_option('seolok_outbox', array_values(array_filter(seolok_sync_outbox(), function ($event) use ($sent) { return !isset($sent[(string) $event['id']]); })), false); update_option('seolok_sync_last', time(), false); if (isset($answer['badge']) && is_numeric($answer['badge'])) { update_option('seolok_badge', max(0, (int) $answer['badge']), false); } seolok_lokman_forget(); // SEOLOK's view of the site just changed: the page cached a few minutes ago is out of date if (seolok_sync_outbox()) { seolok_sync_schedule_flush(); // more than one report's worth was waiting: the rest does not wait for the next hour } return $answer; } /** At the end of a request that queued something urgent: send it now (a short wait at most), unless a report already took it. */ function seolok_sync_flush_now() { if (seolok_sync_enabled() && seolok_sync_outbox()) { seolok_sync_beat(4); } } function seolok_sync_schedule() { if (!wp_next_scheduled(SEOLOK_SYNC_BEAT_HOOK)) { wp_schedule_event(time() + 300, 'hourly', SEOLOK_SYNC_BEAT_HOOK); } } function seolok_sync_schedule_flush() { if (!wp_next_scheduled(SEOLOK_SYNC_FLUSH_HOOK)) { wp_schedule_single_event(max(time() + 20, seolok_sync_backoff_until()), SEOLOK_SYNC_FLUSH_HOOK); } } function seolok_sync_unschedule() { wp_clear_scheduled_hook(SEOLOK_SYNC_BEAT_HOOK); wp_clear_scheduled_hook(SEOLOK_SYNC_FLUSH_HOOK); } add_action(SEOLOK_SYNC_BEAT_HOOK, function () { if (seolok_sync_enabled()) { seolok_sync_beat(); } }); add_action(SEOLOK_SYNC_FLUSH_HOOK, function () { if (seolok_sync_enabled() && seolok_sync_outbox()) { seolok_sync_beat(); } }); add_action('admin_init', function () { if (!seolok_sync_enabled()) { return; } seolok_sync_schedule(); // heals a schedule that got lost // WP-Cron may not run at all (DISABLE_WP_CRON, a site nobody visits): a flush that is overdue goes out from an administrator's own // request instead, a short wait at most, not more often than every two minutes, never while the backoff after a failure runs $ajax = function_exists('wp_doing_ajax') && wp_doing_ajax(); if (!$ajax && seolok_sync_outbox() && seolok_sync_flush_overdue() && seolok_sync_backoff_until() <= time() && time() - (int) get_option('seolok_sync_try', 0) > 2 * MINUTE_IN_SECONDS) { seolok_sync_beat(4); } }); /** Deactivated: SEOLOK hears "off" (instead of "quiet, WP-Cron may not run" hours later); the switch stays, so reactivating resumes the sync. */ register_deactivation_hook(__FILE__, function () { if (seolok_sync_enabled()) { seolok_sync_say_off(); seolok_sync_note('deactivated'); } seolok_sync_unschedule(); wp_clear_scheduled_hook('seolok_daily_check'); // the Alerts screen's daily check (includes/screens.php) wp_clear_scheduled_hook('seolok_bulk_tick'); // a queued bulk run waits (the queue stays) until the plugin is active again }); /** Switch on/off, as the buttons on the Lokman page do. @return array{tone:string,lines:array} */ function seolok_sync_set($on) { if ($on) { if (!seolok_is_configured()) { return array('tone' => 'warning', 'lines' => array(seolok_t('not_connected'))); } update_option('seolok_sync', '1'); seolok_sync_note('on'); seolok_sync_schedule(); $answer = seolok_sync_beat(10, true); if (is_wp_error($answer) && $answer->get_error_code() === 'seolok_unauthorized') { return array('tone' => 'warning', 'lines' => array(seolok_t('bad_token'))); // trying again will not help: the key is wrong } return array('tone' => is_wp_error($answer) ? 'warning' : 'success', 'lines' => array(seolok_t(is_wp_error($answer) ? 'sync_on_pending' : 'sync_on_done'))); } if (seolok_sync_enabled()) { seolok_sync_say_off(); } seolok_sync_stop(); seolok_sync_note('off'); return array('tone' => 'success', 'lines' => array(seolok_t('sync_off_done'))); } /** One last word to SEOLOK that sync is off (with the site's address: a staging copy that kept the token can not switch the real site off). If it fails SEOLOK sees the silence. */ function seolok_sync_say_off() { seolok_remote('POST', '/api/wp/ingest', array('v' => 1, 'off' => true, 'home' => substr(home_url('/'), 0, 300)), 5); } /** Everything the live sync keeps: the switch, the schedule, what waits to be sent, the numbers. */ function seolok_sync_stop() { update_option('seolok_sync', '0'); seolok_sync_unschedule(); foreach (array('seolok_outbox', 'seolok_badge', 'seolok_sync_last', 'seolok_sync_try', 'seolok_sync_backoff') as $name) { delete_option($name); } seolok_lokman_forget(); } /** * The plugin was pointed at another SEOLOK site or address: the consent was given for the old connection. The switch goes off and * what waited for the old one is dropped; the owner switches it on again for the new one. */ add_action('update_option_seolok_site_id', function ($old, $new) { if ((string) $old !== (string) $new && (get_option('seolok_sync') === '1' || seolok_sync_outbox())) { seolok_sync_stop(); seolok_sync_note('reset'); } seolok_lokman_forget(); }, 10, 2); add_action('update_option_seolok_api', function ($old, $new) { if ((string) $old !== (string) $new && (get_option('seolok_sync') === '1' || seolok_sync_outbox())) { seolok_sync_stop(); seolok_sync_note('reset'); } seolok_lokman_forget(); }, 10, 2); /** "Allow search": clears WordPress' "discourage search engines" switch (and nothing else), then reports it right away. */ function seolok_search_allow() { if ((string) get_option('blog_public', '1') !== '0') { return array('tone' => 'success', 'lines' => array(seolok_t('search_on_skip'))); } update_option('blog_public', '1'); if (seolok_sync_enabled()) { seolok_sync_beat(8, true); } return array('tone' => 'success', 'lines' => array(seolok_t('search_on_done'))); } /* ------------------------------------------------------------ settings */ add_action('admin_menu', function () { add_options_page(seolok_t('settings_title'), 'SEOLOK', 'manage_options', 'seolok', 'seolok_settings_page'); }); add_action('admin_init', function () { register_setting('seolok', 'seolok_site_id', array('type' => 'string', 'sanitize_callback' => 'sanitize_text_field')); register_setting('seolok', 'seolok_token', array('type' => 'string', 'sanitize_callback' => 'seolok_sanitize_token')); register_setting('seolok', 'seolok_api', array('type' => 'string', 'sanitize_callback' => 'esc_url_raw')); register_setting('seolok', 'seolok_visitor_features', array('type' => 'string', 'default' => '0', 'sanitize_callback' => 'seolok_sanitize_switch')); add_action('update_option_seolok_visitor_features', function () { delete_transient('seolok_push_config'); }); add_action('update_option_seolok_token', function () { delete_transient('seolok_push_config'); seolok_lokman_forget(); }); }); /** Settings → SEOLOK never shows the saved token, so an empty token field means "keep the saved one". */ function seolok_sanitize_token($value) { $value = sanitize_text_field((string) $value); return $value === '' ? (string) get_option('seolok_token', '') : $value; } /** The "Show SEOLOK to visitors" tick box: on or off, nothing else is stored. */ function seolok_sanitize_switch($value) { return $value === '1' ? '1' : '0'; } /** * The tags the plugin's admin screens print and, on each, the attributes they may carry: every built screen goes through * wp_kses() with this list right before it is echoed. A screen that starts using a new tag or attribute adds it here (the PHP * tests strip the same way and fail on anything this list drops). */ function seolok_allowed_html() { static $allowed = null; if ($allowed !== null) { return $allowed; } $common = 'class id style title role hidden lang dir tabindex data-* aria-hidden aria-label aria-labelledby aria-describedby aria-current aria-expanded aria-controls aria-live aria-pressed aria-selected aria-valuenow aria-valuemin aria-valuemax'; $paint = 'fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray stroke-dashoffset opacity transform vector-effect'; $tags = array( 'a' => 'href target rel download', 'button' => 'type name value form disabled', 'circle' => 'cx cy r pathlength ' . $paint, 'details' => 'open name', 'form' => 'method action target autocomplete', 'g' => $paint, 'img' => 'src alt width height loading decoding', 'input' => 'type name value placeholder checked disabled readonly required min max step maxlength size list form autocomplete', 'label' => 'for', 'line' => 'x1 y1 x2 y2 ' . $paint, 'ol' => 'start', 'optgroup' => 'label', 'option' => 'value selected disabled label', 'path' => 'd pathlength ' . $paint, 'polygon' => 'points ' . $paint, 'polyline' => 'points ' . $paint, 'progress' => 'value max', 'rect' => 'x y width height rx ry ' . $paint, 'select' => 'name form disabled required multiple size autocomplete', 'svg' => 'viewbox width height focusable preserveaspectratio xmlns ' . $paint, 'td' => 'colspan rowspan headers', 'text' => 'x y dx dy text-anchor font-size font-weight ' . $paint, 'textarea' => 'name rows cols placeholder disabled readonly required maxlength form', 'th' => 'colspan rowspan headers scope', 'time' => 'datetime', 'tspan' => 'x y dx dy text-anchor font-size font-weight ' . $paint, ); foreach (explode(' ', 'abbr aside b blockquote br caption code datalist dd del div dl dt em fieldset footer h1 h2 h3 h4 h5 h6 header hr i ins kbd legend li main mark nav p pre s section small span strong sub summary sup table tbody tfoot thead tr u ul') as $tag) { $tags[$tag] = ''; } $allowed = array(); foreach ($tags as $tag => $attributes) { $allowed[$tag] = array_fill_keys(array_filter(explode(' ', $common . ' ' . $attributes)), true); } return $allowed; } function seolok_settings_page() { if (!current_user_can('manage_options')) { return; } echo wp_kses('
' . seolok_screen_head('', seolok_s('menu_settings'), seolok_t('help'), '' . esc_html(seolok_t('open_lokman')) . '') . '
', seolok_allowed_html()); echo '

' . esc_html(seolok_t('sync_label')) . ': ' . esc_html(seolok_t(seolok_sync_enabled() ? 'sync_state_on' : 'sync_state_off')) . '

'; echo '
'; settings_fields('seolok'); echo ''; // the SEOLOK address is for test servers only: shown only when it was changed (an empty value means https://seolok.com) $fields = array('site_id' => 'text', 'token' => 'password'); $api = untrailingslashit((string) get_option('seolok_api', '')); if ($api !== '' && $api !== 'https://seolok.com') { $fields['api'] = 'url'; } foreach ($fields as $field => $type) { $value = get_option('seolok_' . $field, $field === 'api' ? 'https://seolok.com' : ''); // the token is never printed back (not even in the page source); an empty field on save keeps the saved one $saved_token = $field === 'token' && (string) $value !== ''; echo ''; echo ''; } echo ''; echo ''; submit_button(); echo '
'; } /* ------------------------------------------------- Lokman page (WordPress admin) */ /** * "SEOLOK → Lokman": today's jobs, broken-link cleanup and the latest changes. SEOLOK builds the content (numbers, wording * in the site's language, button labels); this code only fetches it (signed, cached 5 minutes), draws it with every value * escaped, and forwards a click to SEOLOK (signed). Nothing is decided here, so rules and wording change without a plugin update. */ define('SEOLOK_FIX_OPS', array('redirect', 'redirect_404', 'rename', 'replace', 'unlink', 'undo', 'undo_batch')); // buttons handled in WordPress itself (nothing is forwarded): the live sync switch and "allow search engines" define('SEOLOK_LOCAL_OPS', array('sync_on', 'sync_off', 'search_on')); define('SEOLOK_LOKMAN_PAGE', 'seolok-lokman'); function seolok_lokman_locale() { $locale = function_exists('get_user_locale') ? get_user_locale() : get_locale(); return strpos((string) $locale, 'tr') === 0 ? 'tr' : 'en'; } function seolok_is_configured() { return seolok_option('site_id') !== '' && seolok_option('token') !== ''; } /** * Signed request to SEOLOK. Reads sign the site id, writes the raw JSON body (HMAC-SHA256 with the site token, same scheme as SEOLOK → plugin). * @return array|WP_Error decoded JSON */ function seolok_remote($method, $path, $payload = null, $timeout = null) { $site = (string) seolok_option('site_id'); $token = (string) seolok_option('token'); if ($site === '' || $token === '') { return new WP_Error('seolok_not_configured', 'Site ID or token missing'); } $ts = time(); $args = array('method' => $method, 'timeout' => $timeout !== null ? (int) $timeout : ($method === 'GET' ? 15 : 45), 'headers' => array('X-Seolok-Site' => $site, 'X-Seolok-Timestamp' => (string) $ts, 'X-Seolok-Plugin' => SEOLOK_VERSION, 'X-Seolok-Sync' => seolok_sync_enabled() ? 'on' : 'off')); if ($method === 'GET') { $args['headers']['X-Seolok-Signature'] = seolok_signature($token, $ts, $site); } else { $body = wp_json_encode($payload); $args['body'] = $body; $args['headers']['Content-Type'] = 'application/json'; $args['headers']['X-Seolok-Signature'] = seolok_signature($token, $ts, $body); } $response = wp_remote_request(seolok_api_base() . $path, $args); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); if ($code === 401) { return new WP_Error('seolok_unauthorized', 'SEOLOK did not accept the signature'); } $data = json_decode((string) wp_remote_retrieve_body($response), true); if ($code !== 200 || !is_array($data)) { return new WP_Error('seolok_remote_error', 'Unexpected answer from SEOLOK (' . $code . ')'); } return $data; } function seolok_lokman_forget() { delete_transient('seolok_lokman_tr'); delete_transient('seolok_lokman_en'); } /** @return array|WP_Error */ function seolok_lokman_data($force = false) { $key = 'seolok_lokman_' . seolok_lokman_locale(); if (!$force) { $cached = get_transient($key); if (is_array($cached)) { return $cached; } } $data = seolok_remote('GET', '/api/wp/lokman?locale=' . seolok_lokman_locale()); if (is_wp_error($data)) { return $data; } if (!isset($data['v']) || !in_array((int) $data['v'], array(1, 2), true)) { return new WP_Error('seolok_remote_error', 'This page needs a newer SEOLOK plugin'); } if (isset($data['badge']) && is_numeric($data['badge'])) { update_option('seolok_badge', max(0, (int) $data['badge']), false); // the number in the menu } set_transient($key, $data, MINUTE_IN_SECONDS); // short: what is decided in SEOLOK shows here within a minute return $data; } function seolok_str($value) { return is_string($value) ? $value : (is_numeric($value) ? (string) $value : ''); } function seolok_arr($value) { return is_array($value) ? $value : array(); } /** Links in SEOLOK's answer only lead to the SEOLOK address this plugin is set up with. */ function seolok_panel_url($url) { $url = seolok_str($url); return strpos($url, seolok_api_base() . '/') === 0 ? esc_url($url) : ''; } /** Placeholder avatar (Lokman the squirrel on a lilac circle, like the site's placeholder until a mascot picture is set); a mascot can replace it through the "seolok_avatar_svg" filter, per state: calm | busy | happy. */ function seolok_avatar($state) { $svg = '🐿️'; $svg = apply_filters('seolok_avatar_svg', $svg, $state); return ''; } function seolok_fix_form_open($extra_hidden) { $out = '
'; $out .= '' . wp_nonce_field('seolok_fix', '_wpnonce', true, false); foreach ($extra_hidden as $name => $value) { $out .= ''; } return $out; } /** The whole page body from SEOLOK's data (every value escaped). */ function seolok_render_lokman($d) { $tasks = seolok_arr(isset($d['tasks']) ? $d['tasks'] : null); $broken = seolok_arr(isset($d['broken']) ? $d['broken'] : null); $history = seolok_arr(isset($d['history']) ? $d['history'] : null); $rows = seolok_arr(isset($broken['rows']) ? $broken['rows'] : null); $items = seolok_arr(isset($tasks['items']) ? $tasks['items'] : null); $state = count($rows) > 0 ? 'busy' : (count($items) === 0 ? 'happy' : 'calm'); $panel = seolok_arr(isset($d['openPanel']) ? $d['openPanel'] : null); $health = isset($d['health']) && is_array($d['health']) ? $d['health'] : null; $o = '
' . seolok_avatar($state) . '
'; $o .= '

' . esc_html(seolok_str(isset($d['title']) ? $d['title'] : 'Lokman')) . ' ' . esc_html(seolok_str(isset($d['subtitle']) ? $d['subtitle'] : '')) . '

'; $o .= '

' . esc_html(seolok_str(isset($d['greeting']) ? $d['greeting'] : '')) . '

'; $o .= '

' . ($health ? esc_html(seolok_str($health['score'])) . ' · ' . esc_html(seolok_str($health['crawled'])) : esc_html(seolok_str(isset($d['neverCrawled']) ? $d['neverCrawled'] : ''))) . '

'; $o .= '
'; // today's jobs $labels = seolok_arr(isset($tasks['labels']) ? $tasks['labels'] : null); $o .= '

' . esc_html(seolok_str(isset($tasks['title']) ? $tasks['title'] : '')) . '

'; if (count($items) === 0) { $o .= '

' . esc_html(seolok_str(isset($tasks['empty']) ? $tasks['empty'] : '')) . '

'; } else { $o .= '
    '; foreach ($items as $task) { $task = seolok_arr($task); $o .= '
  1. ' . esc_html(seolok_str(isset($task['rank']) ? $task['rank'] : '')) . '

    '; if (!empty($task['page'])) { $o .= '

    ' . esc_html(seolok_str(isset($labels['page']) ? $labels['page'] : '')) . ' ' . esc_html(seolok_str($task['page'])) . '

    '; } if (!empty($task['query'])) { $o .= '

    ' . esc_html(seolok_str(isset($labels['query']) ? $labels['query'] : '')) . ' ' . esc_html(seolok_str($task['query'])) . '

    '; } $o .= '

    ' . esc_html(seolok_str(isset($task['reason']) ? $task['reason'] : '')) . '

    '; if (seolok_panel_url(isset($task['url']) ? $task['url'] : '') !== '') { $o .= '

    ' . esc_html(seolok_str(isset($task['cta']) ? $task['cta'] : '')) . '

    '; } $o .= '
  2. '; } $o .= '
'; } $all = seolok_arr(isset($tasks['all']) ? $tasks['all'] : null); if (seolok_panel_url(isset($all['url']) ? $all['url'] : '') !== '') { $o .= '

' . esc_html(seolok_str(isset($all['label']) ? $all['label'] : '')) . '

'; } $o .= '
'; // broken links $o .= '

' . esc_html(seolok_str(isset($broken['title']) ? $broken['title'] : '')) . '

'; $o .= '

' . esc_html(seolok_str(isset($broken['lead']) ? $broken['lead'] : '')) . '

'; if (!empty($broken['count'])) { $o .= '

' . esc_html(seolok_str($broken['count'])) . '

'; } if (!empty($broken['empty'])) { $o .= '

' . esc_html(seolok_str($broken['empty'])) . '

'; } if (count($rows) > 0) { $o .= '
    '; foreach ($rows as $row) { $row = seolok_arr($row); $o .= '
  • ' . esc_html(seolok_str(isset($row['status']) ? $row['status'] : '')) . ' ' . esc_html(seolok_str(isset($row['label']) ? $row['label'] : '')) . ' ' . esc_html(seolok_str(isset($row['countLabel']) ? $row['countLabel'] : '')) . '
    '; $suggestion = seolok_arr(isset($row['suggestion']) ? $row['suggestion'] : null); if (!empty($suggestion['label'])) { $o .= '

    ' . esc_html(seolok_str(isset($suggestion['caption']) ? $suggestion['caption'] : '')) . ' ' . esc_html(seolok_str($suggestion['label'])) . '

    '; } if (!empty($row['note'])) { $o .= '

    ' . esc_html(seolok_str($row['note'])) . '

    '; } if (!empty($row['state'])) { $o .= '

    ✓ ' . esc_html(seolok_str($row['state'])) . '

    '; } $buttons = seolok_arr(isset($row['buttons']) ? $row['buttons'] : null); if (count($buttons) > 0) { $hidden = array('target' => seolok_str(isset($row['target']) ? $row['target'] : '')); if (!empty($suggestion['url'])) { $hidden['to'] = seolok_str($suggestion['url']); } $o .= seolok_fix_form_open($hidden); if (!empty($row['input'])) { $o .= ' ' . seolok_pages_datalist(); } foreach ($buttons as $button) { $button = seolok_arr($button); $op = seolok_str(isset($button['op']) ? $button['op'] : ''); if (!in_array($op, SEOLOK_FIX_OPS, true)) { continue; } $o .= ' '; } $o .= '
  • '; } $o .= ''; } $o .= '
'; } $more = isset($broken['more']) && is_array($broken['more']) ? $broken['more'] : null; if ($more && seolok_panel_url(isset($more['url']) ? $more['url'] : '') !== '') { $o .= '

' . esc_html(seolok_str(isset($more['label']) ? $more['label'] : '')) . '

'; } if (count($rows) > 0) { $o .= '

' . esc_html(seolok_str(isset($broken['perPage']) ? $broken['perPage'] : '')) . '

'; } $o .= '
'; // latest changes $entries = seolok_arr(isset($history['items']) ? $history['items'] : null); if (count($entries) > 0) { $o .= '

' . esc_html(seolok_str(isset($history['title']) ? $history['title'] : '')) . '

'; } return $o; } /* ---- the overview page (SEOLOK's data v2), drawn the way WordPress draws its own screens (0.6.3) */ define('SEOLOK_CARD_TONES', array('ok', 'info', 'warn', 'bad')); define('SEOLOK_CARD_PLACES', array('todo', 'ok', 'status', 'log')); function seolok_op_allowed($op) { return in_array($op, SEOLOK_FIX_OPS, true) || in_array($op, SEOLOK_LOCAL_OPS, true); } /** The buttons of a form: only operations this plugin knows are drawn, whatever SEOLOK's answer says. */ function seolok_render_buttons($buttons) { $o = ''; foreach (seolok_arr($buttons) as $button) { $button = seolok_arr($button); $op = seolok_str(isset($button['op']) ? $button['op'] : ''); if (!seolok_op_allowed($op)) { continue; } $o .= ' '; } return $o; } /** A link button to SEOLOK (only to the address this plugin is set up with), or nothing. */ function seolok_panel_button($link, $class = 'button') { $link = seolok_arr($link); $url = seolok_panel_url(isset($link['url']) ? $link['url'] : ''); return $url === '' ? '' : '' . esc_html(seolok_str(isset($link['label']) ? $link['label'] : '')) . ''; } /** One row of a topic (a broken address, a job, an event, a change) as a table row: what it is on the left, what can be done on the right. */ function seolok_render_card_row($row) { $row = seolok_arr($row); $title = esc_html(seolok_str(isset($row['title']) ? $row['title'] : '')); $o = '

'; if (!empty($row['chip'])) { $o .= '' . esc_html(seolok_str($row['chip'])) . ' '; } $o .= !empty($row['code']) ? '' . $title . '' : '' . $title . ''; if (!empty($row['meta'])) { $o .= ' ' . esc_html(seolok_str($row['meta'])) . ''; } $o .= '

'; foreach (seolok_arr(isset($row['lines']) ? $row['lines'] : null) as $line) { $line = seolok_arr($line); if (empty($line['text'])) { continue; } $text = esc_html(seolok_str($line['text'])); $o .= '

' . (!empty($line['label']) ? '' . esc_html(seolok_str($line['label'])) . ' ' : '') . (!empty($line['code']) ? '' . $text . '' : '' . $text . '') . '

'; } if (!empty($row['note'])) { $o .= '

' . esc_html(seolok_str($row['note'])) . '

'; } if (!empty($row['done'])) { $o .= '

✓ ' . esc_html(seolok_str($row['done'])) . '

'; } $o .= ''; $form = isset($row['form']) && is_array($row['form']) ? $row['form'] : null; if ($form) { $buttons = seolok_render_buttons(isset($form['buttons']) ? $form['buttons'] : null); if ($buttons !== '') { $hidden = array(); foreach (seolok_arr(isset($form['hidden']) ? $form['hidden'] : null) as $name => $value) { if (in_array($name, array('target', 'to', 'id'), true)) { $hidden[$name] = seolok_str($value); } } $o .= seolok_fix_form_open($hidden); if (!empty($form['input'])) { $o .= ' ' . seolok_pages_datalist(); } $o .= $buttons . ''; } } $o .= seolok_panel_button(isset($row['link']) ? $row['link'] : null); return $o . ''; } /** * The site's published pages and posts as suggestions for the "new address" boxes of the broken-link rows (0.6.1): the browser lists * the ones matching what is typed. Drawn once per screen; newest changed first, at most 500. */ function seolok_pages_datalist() { static $done = false; if ($done) { return ''; } $done = true; $types = array_values(array_filter(array('page', 'post'), 'post_type_exists')); $ids = get_posts(array('post_type' => $types, 'post_status' => 'publish', 'numberposts' => 500, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => false)); $o = ''; foreach (is_array($ids) ? $ids : array() as $id) { $path = (string) wp_parse_url((string) get_permalink($id), PHP_URL_PATH); if ($path === '' || $path === '/') { continue; } $o .= ''; } return $o . ''; } function seolok_card_tone($card) { return isset($card['tone']) && in_array($card['tone'], SEOLOK_CARD_TONES, true) ? $card['tone'] : 'info'; } /** Where a topic goes. A SEOLOK from before plugin 0.6.3 sends no place: worked out from the card the same way. */ function seolok_card_place($card) { if (isset($card['place']) && in_array($card['place'], SEOLOK_CARD_PLACES, true)) { return $card['place']; } $id = seolok_str(isset($card['id']) ? $card['id'] : ''); if (!empty($card['compact'])) { return $id === 'search' ? 'status' : 'ok'; } return $id === 'sync' ? 'status' : ($id === 'history' ? 'log' : 'todo'); } /** The topic's sentence; from an older SEOLOK (no "line"): title and number, or the value of a status row. */ function seolok_card_line($card, $place) { if (!empty($card['line'])) { return seolok_str($card['line']); } $title = seolok_str(isset($card['title']) ? $card['title'] : ''); $big = seolok_str(isset($card['big']) ? $card['big'] : ''); $lead = seolok_str(isset($card['lead']) ? $card['lead'] : ''); if ($place === 'ok' || $place === 'log') { return $lead !== '' ? $lead : $title; } if ($place === 'status') { return $big !== '' ? $big : $lead; } return $big !== '' ? $title . ': ' . $big : ($lead !== '' ? $lead : $title); } function seolok_card_sub($card, $place) { if (isset($card['line'])) { return seolok_str(isset($card['sub']) ? $card['sub'] : ''); } return $place !== 'log' && !empty($card['big']) ? seolok_str(isset($card['lead']) ? $card['lead'] : '') : ''; } /** WordPress' own icon font (dashicons) for a tone; the colour comes from the "seolok-tone--…" class. */ function seolok_tone_icon($tone) { $icon = $tone === 'ok' ? 'dashicons-yes-alt' : ($tone === 'info' ? 'dashicons-info-outline' : 'dashicons-warning'); return ''; } /** The rows of a topic as a table, with the topic's link and small print under it. */ function seolok_card_body($card) { $rows = seolok_arr(isset($card['rows']) ? $card['rows'] : null); $o = ''; if (count($rows) > 0) { $o .= ''; foreach ($rows as $row) { $o .= seolok_render_card_row($row); } $o .= '
'; } $link = seolok_panel_button(isset($card['link']) ? $card['link'] : null, 'seolok-more'); if ($link !== '') { $o .= '

' . $link . '

'; } if (!empty($card['foot'])) { $o .= '

' . esc_html(seolok_str($card['foot'])) . '

'; } return $o; } /** The topic's own buttons (a form) and, when it has no rows to open, its link as a button. */ function seolok_card_actions($card, $with_link) { $o = ''; $buttons = seolok_render_buttons(isset($card['buttons']) ? $card['buttons'] : null); if ($buttons !== '') { $o .= seolok_fix_form_open(array()) . $buttons . ''; } if ($with_link) { $o .= seolok_panel_button(isset($card['link']) ? $card['link'] : null); } return $o; } /** * One line of the to-do list: icon, the sentence with its number, and one button. With rows, the button opens them under the line * (no script: a details element); otherwise the topic's own buttons or its link stand on the line. */ function seolok_render_todo($card) { $tone = seolok_card_tone($card); $rows = seolok_arr(isset($card['rows']) ? $card['rows'] : null); $sub = seolok_card_sub($card, 'todo'); $text = '' . esc_html(seolok_card_line($card, 'todo')) . '' . ($sub !== '' ? '' . esc_html($sub) . '' : '') . ''; $id = preg_replace('/[^a-z0-9_-]/', '', strtolower(seolok_str(isset($card['id']) ? $card['id'] : ''))); $o = '
  • '; if (count($rows) > 0) { $label = !empty($card['action']) ? seolok_str($card['action']) : (!empty($card['detailsLabel']) ? seolok_str($card['detailsLabel']) : '…'); // today's jobs are what Lokman's sentence talks about: shown open, not behind a button $o .= '' . seolok_tone_icon($tone) . $text . '' . esc_html($label) . ''; $actions = seolok_card_actions($card, false); $o .= '
    ' . ($actions !== '' ? '
    ' . $actions . '
    ' : '') . seolok_card_body($card) . '
    '; } else { $actions = seolok_card_actions($card, true); $o .= '
    ' . seolok_tone_icon($tone) . $text . ($actions !== '' ? '' . $actions . '' : '') . '
    '; if (!empty($card['foot'])) { $o .= '

    ' . esc_html(seolok_str($card['foot'])) . '

    '; } } return $o . '
  • '; } /** A row of the status table (live sync, search engines) or the change log: name, value with small print, details folded, buttons. */ function seolok_render_status_row($card, $place) { $tone = seolok_card_tone($card); $rows = seolok_arr(isset($card['rows']) ? $card['rows'] : null); $sub = seolok_card_sub($card, $place); $o = '' . esc_html(seolok_str(isset($card['title']) ? $card['title'] : '')) . ''; $o .= ($place === 'log' ? '' : seolok_tone_icon($tone)) . '' . esc_html(seolok_card_line($card, $place)) . ''; if ($sub !== '') { $o .= '

    ' . esc_html($sub) . '

    '; } if (count($rows) > 0) { $label = !empty($card['action']) ? seolok_str($card['action']) : (!empty($card['detailsLabel']) ? seolok_str($card['detailsLabel']) : '…'); $o .= '
    ' . esc_html($label) . '' . seolok_card_body($card) . '
    '; } return $o . '' . seolok_card_actions($card, count($rows) === 0) . ''; } /** * The overview (0.6.8): a heading with Lokman's sentence, the board (four numbers, to-do list beside the site summary, activity, * shortcuts, opportunities) when SEOLOK sends one, and the status table (live sync, search engines, recent changes) at the bottom. * The to-do list keeps every button of the topics (fix, 301, undo…). Every value escaped where it is added. */ function seolok_render_lokman_cards($d) { $cards = seolok_arr(isset($d['cards']) ? $d['cards'] : null); $todo = ''; $oks = ''; $status = ''; $log = ''; $busy = false; foreach ($cards as $card) { $card = seolok_arr($card); $place = seolok_card_place($card); $tone = seolok_card_tone($card); if ($place === 'todo') { $busy = $busy || $tone === 'warn' || $tone === 'bad'; $todo .= seolok_render_todo($card); } elseif ($place === 'ok') { $oks .= '
  • ' . seolok_tone_icon($tone === 'ok' ? 'ok' : 'info') . esc_html(seolok_card_line($card, 'ok')) . '
  • '; } elseif ($place === 'status') { $status .= seolok_render_status_row($card, 'status'); } else { $log .= seolok_render_status_row($card, 'log'); } } $list = ($todo !== '' ? '' : '

    ' . seolok_tone_icon('ok') . esc_html(seolok_t('todo_empty')) . '

    ') . ($oks !== '' ? '' : ''); $o = '

    ' . esc_html(seolok_s('menu_overview')) . '

    ' . esc_html(seolok_s('ov_lead')) . '

    '; $o .= '
    ' . seolok_avatar($busy ? 'busy' : 'happy') . '

    ' . esc_html(seolok_str(isset($d['title']) ? $d['title'] : 'Lokman')) . ' ' . esc_html(seolok_str(isset($d['subtitle']) ? $d['subtitle'] : '')) . '
    ' . esc_html(seolok_str(isset($d['greeting']) ? $d['greeting'] : '')) . '

    ' . seolok_dash_jobs_button($cards) . seolok_dash_ask($d) . '
    '; if (isset($d['dash']) && is_array($d['dash'])) { $o .= seolok_render_dash($d, $list); } else { $o .= '
    ' . seolok_ui_icon('check') . '

    ' . esc_html(seolok_t('todo_title')) . '

    ' . $list . '
    '; } if ($status !== '' || $log !== '') { $o .= '
    ' . seolok_ui_icon('gear') . '

    ' . esc_html(seolok_t('status_title')) . '

    ' . $status . $log . '
    '; } return $o; } /** A whole number in the admin's language (1.234 / 1,234). */ function seolok_num($n) { return number_format((float) $n, 0, seolok_is_tr() ? ',' : '.', seolok_is_tr() ? '.' : ','); } /** A tiny line chart of a series (no axes, no labels: the number next to it carries the meaning). `$lower_better` flips it (positions). */ function seolok_sparkline($series, $tone = 'ok', $lower_better = false) { $points = array(); $values = array(); foreach (seolok_arr($series) as $i => $v) { if (is_numeric($v)) { $values[(int) $i] = (float) $v; } } if (count($values) < 2) { return ''; } $min = min($values); $max = max($values); $span = $max - $min > 0 ? $max - $min : 1; $n = max(array_keys($values)); foreach ($values as $i => $v) { $y = ($v - $min) / $span; $y = $lower_better ? $y : 1 - $y; $points[] = round($i / max(1, $n) * 96 + 2, 1) . ',' . round($y * 24 + 3, 1); } return ''; } /** "▲ 18 %" / "▼ 3,2": how a number moved against the window before; `$good` says whether that move is good news. */ function seolok_delta($text, $good) { return '' . ($good ? '▲' : '▼') . ' ' . esc_html($text) . ''; } /** The score as a ring (stroke length = score). */ function seolok_ring($score) { $score = max(0, min(100, (int) $score)); $len = round($score / 100 * 276.5, 1); $tone = $score >= 80 ? 'ok' : ($score >= 60 ? 'warn' : 'bad'); return '
    ' . esc_html((string) $score) . '/ 100
    '; } /** A headline number card of the board; the whole card is a link (to SEOLOK, or to a place on this page). */ function seolok_dash_kpi($icon, $tone, $label, $value, $suffix, $sub, $href, $external) { $attrs = $external ? ' target="_blank" rel="noopener noreferrer"' : ''; return '' . seolok_ui_icon($icon) . '' . esc_html($label) . '' . esc_html($value) . ($suffix !== '' ? '' . esc_html($suffix) . '' : '') . '' . seolok_ui_icon('chev') . '' . $sub . ''; } /** The biggest issues of SEOLOK's last scan as to-do rows ("Pages without a title · 4 pages"), each opening the plugin's Site analysis screen. */ function seolok_dash_issue_rows($dash) { $rows = seolok_arr(isset($dash['issuesTop']) ? $dash['issuesTop'] : null); if (!$rows) { return ''; } $url = esc_url(admin_url('admin.php?page=' . seolok_insight_slug('audit'))); $o = '

    ' . esc_html(seolok_s('ov_from_scan')) . '

    '; } /** Lokman's sentence counts today's jobs: this button goes to them (the line of the "today" to-do topic). */ function seolok_dash_jobs_button($cards) { foreach ($cards as $card) { $card = seolok_arr($card); if (isset($card['id']) && $card['id'] === 'today' && seolok_card_place($card) === 'todo' && count(seolok_arr(isset($card['rows']) ? $card['rows'] : null)) > 0) { return '' . seolok_ui_icon('list') . esc_html(sprintf(seolok_s('ov_go_jobs'), count(seolok_arr($card['rows'])))) . ''; } } return ''; } /** "Ask a question": SEOLOK's support page of this site, where the team answers (when SEOLOK sent the link). */ function seolok_dash_ask($d) { $dash = isset($d['dash']) ? seolok_arr($d['dash']) : array(); $links = seolok_arr(isset($dash['links']) ? $dash['links'] : null); $url = seolok_panel_url(isset($links['support']) ? $links['support'] : ''); return $url === '' ? '' : '' . seolok_ui_icon('help') . esc_html(seolok_s('ov_ask')) . ''; } /** The board on top of the overview (plugin 0.6.8): four numbers, the to-do list beside the site summary, then activity, shortcuts and opportunities. */ function seolok_render_dash($d, $todo_html) { $dash = seolok_arr($d['dash']); $links = seolok_arr(isset($dash['links']) ? $dash['links'] : null); $link = function ($key) use ($links) { return seolok_panel_url(isset($links[$key]) ? $links[$key] : ''); }; $issues = seolok_arr(isset($dash['issues']) ? $dash['issues'] : null); $crit = isset($issues['critical']) ? (int) $issues['critical'] : 0; $warn = isset($issues['warning']) ? (int) $issues['warning'] : 0; $score = isset($dash['score']) && is_numeric($dash['score']) ? (int) $dash['score'] : null; $broken = isset($dash['brokenOpen']) ? (int) $dash['brokenOpen'] : 0; $tasks = isset($dash['tasks']) ? (int) $dash['tasks'] : 0; $search = isset($dash['search']) && is_array($dash['search']) ? $dash['search'] : null; $crawled = seolok_str(isset($dash['crawledAt']) ? $dash['crawledAt'] : ''); $o = '
    '; $o .= seolok_dash_kpi('chain', $broken > 0 ? 'bad' : 'ok', seolok_s('ov_broken'), seolok_num($broken), '', esc_html($broken > 0 ? seolok_s('ov_broken_sub') : seolok_s('ov_broken_none')), $broken > 0 ? '#seolok-todo' : $link('links'), $broken === 0); $o .= seolok_dash_kpi('chart', $score === null ? 'muted' : ($score >= 80 ? 'ok' : ($score >= 60 ? 'warn' : 'bad')), seolok_s('ov_score'), $score === null ? '—' : (string) $score, $score === null ? '' : ' / 100', esc_html($score === null ? seolok_s('ov_score_never') : sprintf(seolok_s('ov_score_sub'), $crawled)), $link('audit'), true); $o .= seolok_dash_kpi('list', 'info', seolok_s('ov_tasks'), seolok_num($tasks), '', esc_html($tasks > 0 ? seolok_s('ov_tasks_sub') : seolok_s('ov_tasks_none')), $tasks > 0 ? '#seolok-todo' : $link('opportunities'), $tasks === 0); $o .= seolok_dash_kpi('warn', $crit > 0 ? 'bad' : ($warn > 0 ? 'warn' : 'ok'), seolok_s('ov_issues'), seolok_num($crit + $warn), '', '' . esc_html(sprintf(seolok_s('ov_critical'), $crit)) . ', ' . esc_html(sprintf(seolok_s('ov_warning'), $warn)) . '', $link('audit'), true); $o .= '
    '; // to-do list | site summary $o .= '
    ' . seolok_ui_icon('check') . '

    ' . esc_html(seolok_s('ov_todo')) . '

    ' . esc_html(seolok_s('ov_todo_lead')) . '

    ' . ($link('opportunities') !== '' ? '' . esc_html(seolok_s('ov_all')) . ' →' : '') . '
    ' . $todo_html . seolok_dash_issue_rows($dash) . '
    '; $o .= '
    ' . seolok_ui_icon('chart') . '

    ' . esc_html(seolok_s('ov_summary')) . '

    ' . esc_html(seolok_s('ov_summary_lead')) . '

    ' . ($link('audit') !== '' ? '' . esc_html(seolok_s('ov_detail')) . ' →' : '') . '
    '; $o .= '
    ' . ($score === null ? '

    ' . esc_html(seolok_s('ov_score_never')) . '

    ' : seolok_ring($score) . '

    ' . esc_html(seolok_s('ov_score')) . '

    ') . '
      '; $pages = isset($dash['pages']) ? (int) $dash['pages'] : 0; $o .= '
    • ' . seolok_ui_icon('file') . '' . esc_html(seolok_s('ov_pages')) . '' . esc_html($pages > 0 ? seolok_num($pages) : '—') . '
    • '; if ($search) { $clicks = (int) $search['clicks']; $prev = isset($search['clicksPrev']) ? (int) $search['clicksPrev'] : 0; $delta = ''; if ($prev > 0 && $clicks !== $prev) { $pct = round(($clicks - $prev) / $prev * 100); $delta = seolok_delta('%' . abs($pct), $pct > 0); } $o .= '
    • ' . seolok_ui_icon('eye') . '' . esc_html(seolok_s('ov_clicks')) . '' . esc_html(seolok_num($clicks)) . ' ' . $delta . '' . seolok_sparkline(isset($search['clicksSeries']) ? $search['clicksSeries'] : array(), 'ok') . '
    • '; $pos = isset($search['position']) && is_numeric($search['position']) ? (float) $search['position'] : null; $pos_prev = isset($search['positionPrev']) && is_numeric($search['positionPrev']) ? (float) $search['positionPrev'] : null; $pdelta = $pos !== null && $pos_prev !== null && abs($pos_prev - $pos) >= 0.1 ? seolok_delta(number_format(abs($pos_prev - $pos), 1, seolok_is_tr() ? ',' : '.', ''), $pos < $pos_prev) : ''; $o .= '
    • ' . seolok_ui_icon('chart') . '' . esc_html(seolok_s('ov_position')) . '' . esc_html($pos === null ? '—' : number_format($pos, 1, seolok_is_tr() ? ',' : '.', '')) . ' ' . $pdelta . '' . seolok_sparkline(isset($search['positionSeries']) ? $search['positionSeries'] : array(), 'info', true) . '
    • '; $o .= '
    • ' . seolok_ui_icon('search') . '' . esc_html(seolok_s('ov_impressions')) . '' . esc_html(seolok_num((int) $search['impressions'])) . '
    • '; } else { $o .= '
    • ' . seolok_ui_icon('eye') . '' . esc_html(seolok_s('ov_clicks')) . '' . esc_html(seolok_s('ov_no_gsc')) . '
    • '; } $ref = isset($dash['referringDomains']) && is_numeric($dash['referringDomains']) ? (int) $dash['referringDomains'] : null; $o .= '
    • ' . seolok_ui_icon('chain') . '' . esc_html(seolok_s('in_ref_domains')) . '' . esc_html($ref === null ? '—' : seolok_num($ref)) . '' . seolok_ui_icon('chev') . '
    • '; $o .= '
    ' . ($search ? '

    ' . esc_html(seolok_s('ov_window')) . '

    ' : '') . '
    '; // activity | shortcuts | opportunities $o .= '
    ' . seolok_ui_icon('clock') . '

    ' . esc_html(seolok_s('ov_activity')) . '

    ' . esc_html(seolok_s('ov_activity_lead')) . '

    '; $activity = seolok_arr(isset($dash['activity']) ? $dash['activity'] : null); if (!$activity) { $o .= '

    ' . esc_html(seolok_s('ov_activity_none')) . '

    '; } else { $o .= '
      '; foreach ($activity as $a) { $a = seolok_arr($a); $tone = isset($a['tone']) && in_array($a['tone'], array('ok', 'info', 'warn', 'bad'), true) ? $a['tone'] : 'info'; $sub = seolok_str(isset($a['sub']) ? $a['sub'] : ''); $o .= '
    1. ' . esc_html(seolok_str(isset($a['text']) ? $a['text'] : '')) . ($sub !== '' ? '' . esc_html($sub) . '' : '') . '' . esc_html(seolok_str(isset($a['when']) ? $a['when'] : '')) . '
    2. '; } $o .= '
    '; } $o .= '
    '; $quick = array( array('search', seolok_s('ov_q_alerts'), esc_url(admin_url('admin.php?page=' . SEOLOK_ALERTS_PAGE)), false), array('chain', seolok_s('ov_q_links'), $link('links'), true), array('chart', seolok_s('ov_q_competitors'), $link('competitors'), true), array('search', seolok_s('ov_q_rank'), $link('rankFinder'), true), array('file', seolok_s('ov_q_report'), $link('reports'), true), ); $o .= '
    ' . seolok_ui_icon('bolt') . '

    ' . esc_html(seolok_s('ov_quick')) . '

    ' . esc_html(seolok_s('ov_quick_lead')) . '

    '; $opps = seolok_arr(isset($dash['opportunities']) ? $dash['opportunities'] : null); $o .= '
    ' . seolok_ui_icon('star') . '

    ' . esc_html(seolok_s('ov_opps')) . '

    ' . esc_html(seolok_s('ov_opps_lead')) . '

    ' . ($link('opportunities') !== '' && $opps ? '' . esc_html(seolok_s('ov_all')) . ' →' : '') . '
    '; if (!$opps) { $o .= '

    ' . esc_html(seolok_s('ov_opps_none')) . '

    '; } else { $o .= '
      '; foreach ($opps as $i => $p) { $p = seolok_arr($p); $facts = array(); if (isset($p['impressions']) && is_numeric($p['impressions'])) { $facts[] = sprintf(seolok_s('ov_opp_seen'), seolok_num($p['impressions'])); } if (isset($p['position']) && is_numeric($p['position'])) { $facts[] = sprintf(seolok_s('ov_opp_pos'), number_format((float) $p['position'], 1, seolok_is_tr() ? ',' : '.', '')); } $o .= '
    1. ' . esc_html((string) ($i + 1)) . '' . esc_html(seolok_str(isset($p['query']) ? $p['query'] : '')) . '' . esc_html(implode(' · ', $facts)) . '' . ($link('opportunities') !== '' ? '' . esc_html(seolok_s('ov_look')) . '' : '') . '
    2. '; } $o .= '
    '; } $o .= '
    '; return $o; } function seolok_flash_key() { return 'seolok_flash_' . (int) get_current_user_id(); } /** The result of the last click (kept for a short while across the redirect), drawn as a WordPress notice. */ function seolok_render_flash($flash) { if (!is_array($flash) || empty($flash['lines']) || !is_array($flash['lines'])) { return ''; } $class = isset($flash['tone']) && $flash['tone'] === 'success' ? 'notice notice-success' : 'notice notice-warning'; $o = '
    '; foreach ($flash['lines'] as $line) { $o .= '

    ' . esc_html(seolok_str($line)) . '

    '; } return $o . '
    '; } function seolok_render_problem($error) { $code = is_wp_error($error) ? $error->get_error_code() : ''; $message = $code === 'seolok_not_configured' ? seolok_t('not_connected') : ($code === 'seolok_unauthorized' ? seolok_t('bad_token') : seolok_t('load_failed')); $o = '

    ' . esc_html($message) . '

    '; $o .= '' . esc_html(seolok_t('go_settings')) . ' '; if ($code !== 'seolok_not_configured') { $o .= '' . esc_html(seolok_t('retry')) . ''; } return $o . '

    '; } /** * Under the Lokman page: which SEOLOK site this WordPress is connected to. SEOLOK opens a site only in the account that owns it, so a * link that says "not found" there usually means the plugin is connected to a site of another account; this makes that visible. */ function seolok_site_note() { $id = (string) seolok_option('site_id'); if ($id === '') { return ''; } // only the end of the ID: enough to compare with the one SEOLOK shows, without putting the whole key on every screen $tail = strlen($id) > 8 ? '…' . substr($id, -6) : $id; return '

    ' . esc_html(seolok_t('site_note')) . ' ' . esc_html($tail) . ' · ' . esc_html(seolok_t('link_settings')) . '
    ' . esc_html(seolok_t('site_note_hint')) . '

    '; } function seolok_lokman_page() { if (!current_user_can('manage_options')) { return; } echo '
    '; $flash = get_transient(seolok_flash_key()); if ($flash !== false) { delete_transient(seolok_flash_key()); echo wp_kses(seolok_render_flash($flash), seolok_allowed_html()); } if (get_transient('seolok_look_again_' . (int) get_current_user_id())) { delete_transient('seolok_look_again_' . (int) get_current_user_id()); echo ''; } // live sync on and the last report is old: report now, so the page shows today's state ("last word 2 minutes ago") // (the attempt is noted first: SEOLOK slow or unreachable must not make every page view wait again) if (seolok_sync_enabled() && time() - max((int) get_option('seolok_sync_last', 0), (int) get_option('seolok_sync_try', 0)) > 30 * MINUTE_IN_SECONDS) { update_option('seolok_sync_try', time(), false); seolok_sync_beat(8); seolok_lokman_forget(); } $data = seolok_is_configured() ? seolok_lokman_data(isset($_GET['seolok_refresh'])) /* phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a read-only "fetch again" flag */ : new WP_Error('seolok_not_configured', 'Not configured'); // top bar: the mark with its line, "Refresh" and "Open in SEOLOK" $refresh_url = add_query_arg(array('page' => SEOLOK_LOKMAN_PAGE, 'seolok_refresh' => '1'), admin_url('admin.php')); $panel = !is_wp_error($data) && isset($data['openPanel']) ? seolok_arr($data['openPanel']) : array(); $panel_url = seolok_panel_url(isset($panel['url']) ? $panel['url'] : ''); echo wp_kses('', seolok_allowed_html()); if (is_wp_error($data)) { echo wp_kses(seolok_render_problem($data), seolok_allowed_html()); } else { echo wp_kses((int) $data['v'] === 2 ? seolok_render_lokman_cards($data) : seolok_render_lokman($data), seolok_allowed_html()); } echo wp_kses(seolok_site_note(), seolok_allowed_html()); echo '
    '; } /** The small number next to SEOLOK in the menu (WordPress' own bubble): addresses waiting for the owner. Comes from SEOLOK's last answer; nothing is fetched to draw the menu. */ function seolok_menu_badge() { $count = (int) get_option('seolok_badge', 0); return $count > 0 ? ' ' . esc_html($count > 99 ? '99+' : (string) $count) . '' : ''; } /** "Settings" and "Lokman" next to "Deactivate" in the plugin list, so the plugin is easy to find and open. */ add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) { $mine = array( '' . esc_html(seolok_t('link_settings')) . '', '' . esc_html(seolok_t('link_lokman')) . '', ); return array_merge($mine, is_array($links) ? $links : array()); }); /** Validates a form post and forwards it to SEOLOK. @return array{tone:string,lines:array} the notice to show */ function seolok_process_fix($post) { $op = isset($post['op']) ? sanitize_key(wp_unslash($post['op'])) : ''; if (!seolok_op_allowed($op)) { return array('tone' => 'warning', 'lines' => array(seolok_t('action_failed'))); } // the buttons that change something in WordPress itself are answered here, nothing is forwarded if (in_array($op, SEOLOK_LOCAL_OPS, true)) { $result = $op === 'sync_on' ? seolok_sync_set(true) : ($op === 'sync_off' ? seolok_sync_set(false) : seolok_search_allow()); seolok_lokman_forget(); return $result; } // URLs are passed through untouched (sanitize_text_field would strip %-encoded letters); SEOLOK checks them against its own crawl $clean = function ($key, $max) use ($post) { return isset($post[$key]) && is_string($post[$key]) ? substr(preg_replace('/[\x00-\x1F\x7F]/', '', wp_unslash($post[$key])), 0, $max) : ''; }; $result = seolok_remote('POST', '/api/wp/lokman/fix', array( 'op' => $op, 'locale' => seolok_lokman_locale(), 'target' => $clean('target', 2000), 'to' => $clean('to', 2000), 'id' => preg_replace('/[^0-9a-zA-Z-]/', '', $clean('id', 64)), )); seolok_lokman_forget(); if (is_wp_error($result) || !isset($result['lines']) || !is_array($result['lines'])) { return array('tone' => 'warning', 'lines' => array(is_wp_error($result) && $result->get_error_code() === 'seolok_unauthorized' ? seolok_t('bad_token') : seolok_t('action_failed'))); } return array('tone' => isset($result['tone']) && $result['tone'] === 'success' ? 'success' : 'warning', 'lines' => array_map('seolok_str', array_slice($result['lines'], 0, 12))); } function seolok_handle_fix() { if (!current_user_can('manage_options')) { wp_die(esc_html(seolok_t('forbidden')), '', array('response' => 403)); } check_admin_referer('seolok_fix'); $result = seolok_process_fix($_POST); // phpcs:ignore WordPress.Security.NonceVerification -- checked above set_transient(seolok_flash_key(), $result, 120); // a multi-page fix runs in SEOLOK's background now (0.6.1): the page looks again once, a few seconds later, to show the result $op = isset($_POST['op']) ? sanitize_key(wp_unslash($_POST['op'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification -- checked above if (in_array($op, array('replace', 'unlink', 'undo_batch'), true) && isset($result['tone']) && $result['tone'] === 'success') { set_transient('seolok_look_again_' . (int) get_current_user_id(), 1, 60); } delete_option('seolok_badge'); // the number in the menu comes back with the next fresh page wp_safe_redirect(add_query_arg(array('page' => SEOLOK_LOKMAN_PAGE), admin_url('admin.php'))); exit; } add_action('admin_post_seolok_fix', 'seolok_handle_fix'); // the SEOLOK menu (Overview, SEO titles, Alerts, Live sync, Settings) and its screens require_once __DIR__ . '/includes/screens.php'; require_once __DIR__ . '/includes/insights.php'; require_once __DIR__ . '/includes/home.php';