Changeset 3790680
- Timestamp:
- 11/08/2026 08:24:08 AM (5 hours ago)
- Location:
- clearyvo-seo
- Files:
-
- 10 added
- 22 edited
- 1 copied
-
tags/0.6.5 (copied) (copied from clearyvo-seo/trunk )
-
tags/0.6.5/assets/style.css (modified) (1 diff)
-
tags/0.6.5/clearyvo-seo.php (modified) (1 diff)
-
tags/0.6.5/includes/class-wpsc-admin.php (modified) (6 diffs)
-
tags/0.6.5/includes/class-wpsc-audit.php (modified) (1 diff)
-
tags/0.6.5/includes/class-wpsc-views.php (modified) (9 diffs)
-
tags/0.6.5/readme.txt (modified) (3 diffs)
-
tags/0.6.5/seo/admin/class-cvseo-sidebar.php (modified) (4 diffs)
-
tags/0.6.5/seo/admin/class-cvseo-visual.php (added)
-
tags/0.6.5/seo/assets/css/sidebar.css (modified) (1 diff)
-
tags/0.6.5/seo/assets/css/snippet.css (added)
-
tags/0.6.5/seo/assets/css/visual.css (added)
-
tags/0.6.5/seo/assets/js/admin-snippet.js (added)
-
tags/0.6.5/seo/assets/js/sidebar.js (modified) (6 diffs)
-
tags/0.6.5/seo/assets/js/snippet-core.js (added)
-
tags/0.6.5/seo/boot.php (modified) (3 diffs)
-
tags/0.6.5/seo/includes/class-cvseo-rest.php (modified) (2 diffs)
-
trunk/assets/style.css (modified) (1 diff)
-
trunk/clearyvo-seo.php (modified) (1 diff)
-
trunk/includes/class-wpsc-admin.php (modified) (6 diffs)
-
trunk/includes/class-wpsc-audit.php (modified) (1 diff)
-
trunk/includes/class-wpsc-views.php (modified) (9 diffs)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/seo/admin/class-cvseo-sidebar.php (modified) (4 diffs)
-
trunk/seo/admin/class-cvseo-visual.php (added)
-
trunk/seo/assets/css/sidebar.css (modified) (1 diff)
-
trunk/seo/assets/css/snippet.css (added)
-
trunk/seo/assets/css/visual.css (added)
-
trunk/seo/assets/js/admin-snippet.js (added)
-
trunk/seo/assets/js/sidebar.js (modified) (6 diffs)
-
trunk/seo/assets/js/snippet-core.js (added)
-
trunk/seo/boot.php (modified) (3 diffs)
-
trunk/seo/includes/class-cvseo-rest.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
clearyvo-seo/tags/0.6.5/assets/style.css
r3787975 r3790680 76 76 .wpsc-table td{vertical-align:top} 77 77 @media (max-width:782px){.wpsc-explain{grid-template-columns:1fr}} 78 79 /* 0.6.3: what the last scan found on this page (Image ALT view). */ 80 .wpsc-scanflag { margin: 6px 0 2px; font-size: 13px; color: #8a4b00; } 81 .wpsc-scanflag-badge { display: inline-block; padding: 1px 8px; margin-right: 4px; border-radius: 10px; background: #fcf0e3; border: 1px solid #f0c48a; color: #8a4b00; font-size: 11.5px; font-weight: 600; } -
clearyvo-seo/tags/0.6.5/clearyvo-seo.php
r3788085 r3790680 4 4 * Plugin URI: https://wp-sitechecker.com/ 5 5 * Description: SEO without keyword stuffing: real image ALT text from page context, not filenames. Titles, meta, schema, sitemap, redirects, 404 log, a full site audit and one-click import from Yoast SEO and Rank Math. 6 * Version: 0. 5.36 * Version: 0.6.5 7 7 * Requires at least: 6.2 8 8 * Requires PHP: 7.4 -
clearyvo-seo/tags/0.6.5/includes/class-wpsc-admin.php
r3787975 r3790680 37 37 'alt_part' => $part($gap, $img), 'h_part' => $part($hp, $np), 'm_part' => $part($mp, $np), 38 38 'site_h1' => count($h['site']), 'conflicts' => count($k['conflicts']), 'orphans' => count($l['orphans']), 'menu_only' => count($l['menuOnly']), 39 'alt_rows' => count(WPSC_Audit::alt_problems()), 39 40 'headings_rows' => $hrows + count($h['site']), 'keywords_rows' => $krows, 'meta_rows' => $mrows, 'links_rows' => count($l['orphans']) + count($l['menuOnly']), 40 41 ]; … … 283 284 $type = WPSiteChecker_UI::filter_value('type', array_keys($types)); 284 285 $s = WPSiteChecker_UI::filter_value('s'); 286 // Pages where the last scan found images without ALT (the Overview card and "Show and fix" open this filter). 287 $probs = WPSC_Audit::state() && !empty(WPSC_Audit::state()['finished']) ? WPSC_Audit::alt_problems() : []; 288 $scan = WPSiteChecker_UI::filter_value('scan', ['noalt']); 285 289 $paged = WPSiteChecker_UI::current_page_num(); 286 $q = new WP_Query(['post_type' => $type !== '' ? $type : array_keys($types), 'post_status' => 'publish', 'posts_per_page' => self::PER_PAGE, 'paged' => $paged, 287 's' => $s, 'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'], 'ignore_sticky_posts' => true, 'has_password' => false, 'no_found_rows' => false]); 290 $args = ['post_type' => $type !== '' ? $type : array_keys($types), 'post_status' => 'publish', 'posts_per_page' => self::PER_PAGE, 'paged' => $paged, 291 's' => $s, 'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'], 'ignore_sticky_posts' => true, 'has_password' => false, 'no_found_rows' => false]; 292 if ($scan === 'noalt') $args['post__in'] = $probs ? array_keys($probs) : [0]; 293 $q = new WP_Query($args); 288 294 289 295 WPSC_Views::explain(__('Checks every published page as visitors and Google see it: which images have no ALT text, whether an image ALT contains the page\'s focus keyword, and which ALT texts repeat. Nothing changes until you save an ALT yourself.', 'clearyvo-seo'), … … 307 313 WPSiteChecker_UI::filter_bar([ 308 314 'label' => __('Filter pages', 'clearyvo-seo'), 309 'filters' => [['name' => 'type', 'label' => __('Content type', 'clearyvo-seo'), 'options' => $topts, 'value' => $type]], 315 'filters' => [['name' => 'type', 'label' => __('Content type', 'clearyvo-seo'), 'options' => $topts, 'value' => $type], 316 /* translators: %s: number of pages */ 317 ['name' => 'scan', 'label' => __('Last scan', 'clearyvo-seo'), 'options' => ['' => __('All pages', 'clearyvo-seo'), 'noalt' => sprintf(__('Missing ALT in the last scan (%s)', 'clearyvo-seo'), WPSiteChecker_Family::num(count($probs)))], 'value' => $scan]], 310 318 // Checked in the browser by app.js (the results of each page are computed there): a local checkbox. 311 319 'checks' => [['id' => 'wpsc-only-problems', 'label' => __('Show only pages with problems', 'clearyvo-seo'), 'local' => true]], … … 314 322 echo '<p id="wpsc-summary" class="wpsc-summary" role="status" aria-live="polite"></p><div id="wpsc-sitewide"></div>'; 315 323 if (!$q->have_posts()) { 316 if ($type !== '' || $s !== '' ) WPSiteChecker_UI::empty_filtered(WPSiteChecker_UI::clear_filters_url(['type', 's']));324 if ($type !== '' || $s !== '' || $scan !== '') WPSiteChecker_UI::empty_filtered(WPSiteChecker_UI::clear_filters_url(['type', 's', 'scan'])); 317 325 else WPSiteChecker_UI::empty_state(['title' => __('No published pages', 'clearyvo-seo'), 'text' => __('Publish a page or post: its images are checked here.', 'clearyvo-seo')]); 318 326 return; … … 330 338 . '<div class="wpsc-pagehead"><div><h2>' . esc_html($title) . ' <span class="wpsc-type">' . esc_html($type_obj ? $type_obj->labels->singular_name : $post->post_type) . '</span></h2>' 331 339 . '<div class="wpsc-meta"><strong>' . esc_html__('Focus keyword:', 'clearyvo-seo') . '</strong> ' . ($kw ? esc_html(implode(' · ', $kw)) : '<em>' . esc_html__('not set', 'clearyvo-seo') . '</em>') . '</div>' 340 . (isset($probs[$post->ID]) ? '<div class="wpsc-scanflag"><span class="wpsc-scanflag-badge">' . esc_html__('Last scan', 'clearyvo-seo') . '</span> ' . esc_html(self::alt_flag($probs[$post->ID])) . '</div>' : '') 332 341 . '<div class="wpsc-checks"></div></div>' 333 342 . '<div class="wpsc-headlinks"><a class="button" target="_blank" rel="noopener" href="' . esc_url(get_permalink($post)) . '">' . esc_html__('View page', 'clearyvo-seo') . $nt . '</a>' … … 337 346 wp_reset_postdata(); 338 347 WPSiteChecker_UI::pagination($pager + ['which' => 'bottom']); 348 } 349 350 /** "1 image without ALT, 2 with empty ALT" for one page of the last scan. */ 351 private static function alt_flag(array $p) { 352 $parts = []; 353 /* translators: %s: number of images */ 354 if ($p[0]) $parts[] = sprintf(_n('%s image without ALT', '%s images without ALT', $p[0], 'clearyvo-seo'), WPSiteChecker_Family::num($p[0])); 355 /* translators: %s: number of images */ 356 if ($p[1]) $parts[] = sprintf(_n('%s image with empty ALT', '%s images with empty ALT', $p[1], 'clearyvo-seo'), WPSiteChecker_Family::num($p[1])); 357 return implode(', ', $parts) . ' ' . __('(as the page is shown; the ALT written in the page or by the theme counts, not the Media Library)', 'clearyvo-seo'); 339 358 } 340 359 -
clearyvo-seo/tags/0.6.5/includes/class-wpsc-audit.php
r3787975 r3790680 71 71 static function flush() { self::$cache = null; } 72 72 73 /** Pages where the last scan found content images without ALT or with an empty ALT: id => [no ALT, empty ALT]. */ 74 static function alt_problems() { 75 $out = []; 76 foreach (self::pages() as $id => $d) { 77 $im = (array) ($d['imgs'] ?? []); 78 $no = (int) ($im[1] ?? 0); $empty = (int) ($im[2] ?? 0); 79 if ($no + $empty) $out[(int) $id] = [$no, $empty]; 80 } 81 return $out; 82 } 83 73 84 static function lc($s) { return function_exists('mb_strtolower') ? mb_strtolower((string) $s, 'UTF-8') : strtolower((string) $s); } 74 85 /** Lower case, accents removed, single spaces: "Generálkivitelezés Budapest" == "generalkivitelezes budapest". */ -
clearyvo-seo/tags/0.6.5/includes/class-wpsc-views.php
r3787975 r3790680 4 4 /** 5 5 * Overview and the views that read the site scan (family UI kit). Hooks for add-ons (Clearyvo SEO Pro): 6 * wpsc_overview_cards, wpsc_keywords_before_table, wpsc_orphan_actions, wpsc_links_after_orphans, wpsc_schema_section. 6 * wpsc_overview_cards, wpsc_keywords_before_table, wpsc_meta_before_table, wpsc_orphan_actions, wpsc_links_after_orphans, 7 * wpsc_schema_section. 7 8 */ 8 9 final class WPSC_Views { … … 15 16 $h = '<a href="' . esc_url(self::page_url($id)) . '" target="_blank" rel="noopener">' . esc_html(self::title($id)) . '<span class="screen-reader-text"> ' . esc_html__('(opens in a new tab)', 'clearyvo-seo') . '</span></a>'; 16 17 if (current_user_can('edit_post', $id)) $h .= ' <a class="wpsc-edit" href="' . esc_url(get_edit_post_link($id, 'raw')) . '">' . esc_html__('Edit', 'clearyvo-seo') . '<span class="screen-reader-text"> ' . esc_html(self::title($id)) . '</span></a>'; 18 if (class_exists('CVSEO_Visual')) $h .= CVSEO_Visual::button((int) $id); // Clearyvo SEO snippet editor right here 17 19 return $h; 18 20 } … … 67 69 if (!$can) $note .= ' ' . __('An editor or administrator can scan the site.', 'clearyvo-seo'); 68 70 WPSiteChecker_UI::action_bar([ 69 'buttons' => $can ? [['label' => __('Scan site', 'clearyvo-seo'), 'primary' => true, 'id' => 'wpsc-audit-run']] : [], 71 'buttons' => array_merge($can ? [['label' => __('Scan site', 'clearyvo-seo'), 'primary' => true, 'id' => 'wpsc-audit-run']] : [], 72 class_exists('CVSEO_Visual') && CVSEO_Visual::available() ? [['label' => __('Visual editor', 'clearyvo-seo'), 'primary' => true, 'class' => 'cvseo-vis-btn', 'attrs' => ['data-cvseo-visual' => '0'], 'title' => __('Choose a page and open it in the page editor with the Clearyvo SEO panel', 'clearyvo-seo')]] : []), 70 73 'status' => WPSiteChecker_UI::last_run_text($ts, 'scan'), 71 74 'status_id' => 'wpsc-audit-status', … … 114 117 $cards = [ 115 118 ['alt', __('Image ALT texts', 'clearyvo-seo'), [ 116 [$s['noalt'] + $s['emptyalt'], __('images without ALT text', 'clearyvo-seo'), 'warning' ],119 [$s['noalt'] + $s['emptyalt'], __('images without ALT text', 'clearyvo-seo'), 'warning', ['scan' => 'noalt']], 117 120 [$nokwAlt, __('pages where no image ALT contains the focus keyword', 'clearyvo-seo'), 'notice']]], 118 121 ['headings', __('Headings', 'clearyvo-seo'), [ … … 148 151 $n = (int) $line[0]; 149 152 $sev = isset($line[2]) && in_array($line[2], ['critical', 'warning', 'notice'], true) ? $line[2] : 'warning'; 150 $out[] = ['severity' => $n ? $sev : 'ok', 'count' => $n, 'text' => WPSiteChecker_Family::num($n) . ' ' . (string) $line[1], 'area' => (string) $c[1], 'tab' => sanitize_key((string) $c[0])]; 153 $out[] = ['severity' => $n ? $sev : 'ok', 'count' => $n, 'text' => WPSiteChecker_Family::num($n) . ' ' . (string) $line[1], 'area' => (string) $c[1], 'tab' => sanitize_key((string) $c[0]), 154 'args' => isset($line[3]) && is_array($line[3]) ? array_map('sanitize_key', $line[3]) : []]; 151 155 } 152 156 } … … 233 237 WPSiteChecker_UI::cards([ 234 238 ['label' => __('Score', 'clearyvo-seo'), 'value' => $s ? $s['score'] : null, 'tone' => 'score'], 235 ['label' => __('Images without ALT text', 'clearyvo-seo'), 'value' => $s ? $s['noalt'] + $s['emptyalt'] : null, 'tone' => 'warning', 'url' => $s && $s['noalt'] + $s['emptyalt'] ? WPSC_Admin::url('alt' ) : ''],239 ['label' => __('Images without ALT text', 'clearyvo-seo'), 'value' => $s ? $s['noalt'] + $s['emptyalt'] : null, 'tone' => 'warning', 'url' => $s && $s['noalt'] + $s['emptyalt'] ? WPSC_Admin::url('alt', ['scan' => 'noalt']) : ''], 236 240 ['label' => __('Pages with heading problems', 'clearyvo-seo'), 'value' => $s ? $s['hp'] : null, 'tone' => 'warning', 'url' => $s && $s['hp'] ? WPSC_Admin::url('headings') : ''], 237 241 ['label' => __('Shared focus keywords', 'clearyvo-seo'), 'value' => $s ? $s['conflicts'] : null, 'tone' => 'warning', 'url' => $s && $s['conflicts'] ? WPSC_Admin::url('keywords') : ''], … … 263 267 $h .= '<tr data-severity="' . esc_attr($r['severity']) . '" data-area="' . esc_attr($r['tab']) . '"><td class="column-severity">' . WPSiteChecker_UI::severity_badge_html($r['severity']) . '</td>' 264 268 . '<td class="column-item">' . esc_html($r['text']) . '</td><td class="column-area">' . esc_html($r['area']) . '</td>' 265 . '<td class="column-actions"><a href="' . esc_url(WPSC_Admin::url($r['tab'] )) . '">' . esc_html__('Show and fix', 'clearyvo-seo') . '<span class="screen-reader-text">: ' . esc_html($r['text']) . '</span></a></td></tr>';269 . '<td class="column-actions"><a href="' . esc_url(WPSC_Admin::url($r['tab'], $r['args'] ?? [])) . '">' . esc_html__('Show and fix', 'clearyvo-seo') . '<span class="screen-reader-text">: ' . esc_html($r['text']) . '</span></a></td></tr>'; 266 270 } 267 271 WPSiteChecker_UI::html($h . '</tbody>'); … … 304 308 if (!array_filter(array_column($rows, 'issues'))) { if (!$h['site']) WPSiteChecker_UI::empty_clean(WPSC_Admin::stats()['ts'], 'scan'); return; } 305 309 uasort($rows, function ($a, $b) { return count($b['issues']) <=> count($a['issues']); }); 310 do_action('wpsc_meta_before_table', $rows, $plugin); // Clearyvo SEO Pro: AI description drafts for many pages 306 311 $popts = ['' => __('All problems', 'clearyvo-seo')]; 307 312 foreach ($labels as $k => $l) $popts[$k] = $l[0]; … … 414 419 $rows = WPSC_Audit::meta(); 415 420 uasort($rows, function ($a, $b) { return count($b['issues']) <=> count($a['issues']); }); 421 do_action('wpsc_meta_before_table', $rows, $plugin); // Clearyvo SEO Pro: AI description drafts for many pages 416 422 $popts = ['' => __('All problems', 'clearyvo-seo')]; 417 423 foreach ($labels as $k => $l) $popts[$k] = $l[0]; -
clearyvo-seo/tags/0.6.5/readme.txt
r3788085 r3790680 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 0. 5.37 Stable tag: 0.6.5 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 125 125 126 126 == Changelog == 127 128 = 0.6.5 = 129 * Fix: back button of the page editor, opened from a Clearyvo SEO screen: replaced through the editor's own dashboard-button slot, so it returns to Clearyvo SEO whatever the button looks like; the earlier click handling stays as a fallback. 130 131 = 0.6.4 = 132 * Fix: the page editor's back button now returns to the Clearyvo SEO screen the page was opened from on every WordPress version (the button is found by where it links to, not by its class name, which changed in newer versions). 133 134 = 0.6.3 = 135 * Fix: the Image ALT tab now shows how many pages the last scan found with images without ALT (or with an empty ALT), like the other tabs. 136 * New: "Last scan" filter on the Image ALT tab: "Missing ALT in the last scan" lists only those pages. The Overview card and "Show and fix" open the tab with this filter. 137 * New: each of those pages is marked with what the scan found ("1 image without ALT"). The scan reads the page as it is shown, so an ALT that exists in the Media Library but not in the page (or is dropped by the theme) is still reported; the mark makes that visible. 138 * Opened from a Clearyvo SEO screen with "Visual editor", the page editor's back button returns to that screen instead of the list of pages. 139 140 = 0.6.2 = 141 * Changed: the "Visual editor" button next to "Scan site" asks for the page, then opens it in the page editor with the Clearyvo SEO panel already open (checks, Google preview, snippet editor). The "Visual editor" buttons in the scan lists go there directly. 142 * Look: the "Edit SEO" button of the page editor is a filled button in the admin colour, open or closed. Scan site, Visual editor and Export CSV are the same blue button on the Clearyvo SEO screens. 143 144 = 0.6.1 = 145 * New: Visual editor on the Clearyvo SEO screens. The "Visual editor" button next to "Scan site" lets you pick any page and edit its Google and social snippet right there; every page in the scan lists has its own "Visual editor" button too. Saved with one click, the list row shows the new snippet at once. 146 * Changed: in the page editor the "Edit SEO" button now opens the whole Clearyvo SEO panel (checks, snippet, settings); the small magnifier icon on the toolbar is gone. The snippet editor opens from the panel (the Google preview or "Open the snippet editor"). 147 * Fix: the snippet editor window had its tabs and score drawn over its title on some WordPress versions. It now has its own header (page name, score, close), the tabs and the desktop / mobile switch on one line, and a fixed footer. 148 * For developers: "clearyvo-seo.snippet.field" JavaScript filter (a slot under the title, description and social fields of the snippet editor) and the "wpsc_meta_before_table" action. 149 150 = 0.6.0 = 151 * New: visual snippet editor in the block editor. A big "Edit SEO" button with the live score sits next to Save / Publish and opens it; clicking the Google preview in the side panel opens it too. 152 * Google preview for desktop and mobile with favicon, site name, breadcrumb address and date; title and description are measured in pixels against the width Google shows on each device; the focus phrase is bold, as in the search results. 153 * Variables (post title, separator, site title, category, excerpt, date, year…) insert at the cursor with one click. 154 * Address (slug) can be edited in the same window. 155 * Social sharing tab: Facebook / LinkedIn and X card previews, social image chosen from the Media Library. 127 156 128 157 = 0.5.3 = … … 248 277 == Upgrade Notice == 249 278 279 = 0.6.5 = 280 Back button of the page editor returns to Clearyvo SEO when opened from there. 281 282 = 0.6.4 = 283 Back button of the page editor returns to Clearyvo SEO when opened from there. 284 285 = 0.6.3 = 286 Image ALT tab: count, "Missing ALT in the last scan" filter and per-page marks. Settings and data are unchanged. 287 288 = 0.6.2 = 289 Visual editor opens the page in the page editor with the SEO panel; one button style. Settings and data are unchanged. 290 291 = 0.6.1 = 292 Visual editor on the Clearyvo SEO screens, one "Edit SEO" button in the page editor, tidier snippet editor window. Settings and data are unchanged. 293 294 = 0.6.0 = 295 New visual snippet editor: "Edit SEO" button next to Publish, desktop / mobile Google preview, variable chips, social card previews. Settings and data are unchanged. 296 250 297 = 0.5.0 = 251 298 Unified Clearyvo interface: sub-navigation, summary cards, filterable findings with Export CSV, one Settings form. Settings and data are unchanged. -
clearyvo-seo/tags/0.6.5/seo/admin/class-cvseo-sidebar.php
r3787975 r3790680 1 1 <?php 2 2 /** 3 * Block editor sidebar: live checks by topic, fields, Google preview and a status light. 3 * Block editor sidebar: live checks by topic, fields, Google preview, a status light and the snippet editor 4 * (modal with desktop / mobile Google preview and social cards), opened from a big button in the editor header. 4 5 * 5 6 * @package ClearyvoSEO … … 29 30 30 31 /** 32 * The shared snippet core (helpers, analysis, snippet editor window) and its styles, for the block editor and the 33 * admin "Visual editor". 34 */ 35 public static function register_core() { 36 $deps = array( 'wp-element', 'wp-components', 'wp-hooks' ); 37 foreach ( array( 'wp-block-editor', 'wp-media-utils' ) as $opt ) { 38 if ( wp_script_is( $opt, 'registered' ) ) { 39 $deps[] = $opt; 40 } 41 } 42 wp_register_script( 'cvseo-snippet-core', CVSEO_URL . 'assets/js/snippet-core.js', $deps, CVSEO_VERSION, true ); 43 wp_register_style( 'cvseo-snippet', CVSEO_URL . 'assets/css/snippet.css', array( 'wp-components' ), CVSEO_VERSION ); 44 } 45 46 /** 47 * Settings every snippet editor needs, whatever page it edits. 48 * 49 * @return array 50 */ 51 public static function base_config() { 52 return array( 53 'sitename' => get_bloginfo( 'name' ), 54 'tagline' => get_bloginfo( 'description' ), 55 'sep' => CVSEO_Settings::get( 'separator' ), 56 'autoDesc' => (bool) CVSEO_Settings::get( 'auto_description' ), 57 'home' => home_url( '/' ), 58 'homeHost' => wp_parse_url( home_url(), PHP_URL_HOST ), 59 'favicon' => get_site_icon_url( 32 ), 60 'blogPublic' => (bool) get_option( 'blog_public' ), 61 'maxDensity' => CVSEO_Analysis::MAX_DENSITY, 62 'pro' => defined( 'WPSCP_VERSION' ), 63 'proUrl' => class_exists( 'WPSiteChecker_Family' ) ? WPSiteChecker_Family::shop_url() : 'https://wp-sitechecker.com/pro/', 64 'auditUrl' => admin_url( 'admin.php?page=wp-seochecker&tab=alt&s=' ), 65 'schema' => CVSEO_Meta::schema_types(), 66 'logo' => class_exists( 'WPSiteChecker_Family' ) ? WPSiteChecker_Family::logo_url( 'seochecker' ) : plugins_url( 'family/logos/seochecker.svg', CVSEO_FILE ), 67 'conflict' => ! CVSEO_Settings::output_enabled() ? implode( ', ', CVSEO_Settings::conflicts() ) : '', 68 'backUrl' => admin_url( 'admin.php?page=wp-seochecker' ), 69 ); 70 } 71 72 /** 31 73 * Script and data. 32 74 */ … … 43 85 $template = $ctx && $ctx->is_front ? CVSEO_Settings::get( 'title_home' ) : CVSEO_Settings::title_template_pt( $post->post_type ); 44 86 $deps = array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-api-fetch', 'wp-url', 'wp-hooks', 'wp-edit-post' ); 45 if ( wp_script_is( 'wp-editor', 'registered' ) ) { 46 $deps[] = 'wp-editor'; 87 foreach ( array( 'wp-editor', 'wp-block-editor', 'wp-date' ) as $opt ) { 88 if ( wp_script_is( $opt, 'registered' ) ) { 89 $deps[] = $opt; 90 } 47 91 } 92 self::register_core(); 93 $deps[] = 'cvseo-snippet-core'; 48 94 wp_enqueue_script( 'cvseo-sidebar', CVSEO_URL . 'assets/js/sidebar.js', $deps, CVSEO_VERSION, true ); 49 wp_enqueue_style( 'cvseo-sidebar', CVSEO_URL . 'assets/css/sidebar.css', array( ), CVSEO_VERSION );95 wp_enqueue_style( 'cvseo-sidebar', CVSEO_URL . 'assets/css/sidebar.css', array( 'cvseo-snippet' ), CVSEO_VERSION ); 50 96 51 97 $cats = array(); … … 58 104 'cvseo-sidebar', 59 105 'cvseoSidebar', 60 array( 61 'template' => $template, 62 'sitename' => get_bloginfo( 'name' ), 63 'tagline' => get_bloginfo( 'description' ), 64 'sep' => CVSEO_Settings::get( 'separator' ), 65 'autoDesc' => (bool) CVSEO_Settings::get( 'auto_description' ), 66 'home' => home_url( '/' ), 67 'homeHost' => wp_parse_url( home_url(), PHP_URL_HOST ), 68 'blogPublic' => (bool) get_option( 'blog_public' ), 69 'ptNoindex' => CVSEO_Settings::pt_noindex( $post->post_type ), 70 'isPage' => 'page' === $post->post_type, 71 'maxDensity' => CVSEO_Analysis::MAX_DENSITY, 72 'pro' => defined( 'WPSCP_VERSION' ), 73 'proUrl' => class_exists( 'WPSiteChecker_Family' ) ? WPSiteChecker_Family::shop_url() : 'https://wp-sitechecker.com/pro/', 74 'auditUrl' => admin_url( 'admin.php?page=wp-seochecker&tab=alt&s=' ), 75 'schema' => CVSEO_Meta::schema_types(), 76 'cats' => (object) $cats, 77 'logo' => class_exists( 'WPSiteChecker_Family' ) ? WPSiteChecker_Family::logo_url( 'seochecker' ) : plugins_url( 'family/logos/seochecker.svg', CVSEO_FILE ), 78 'conflict' => ! CVSEO_Settings::output_enabled() ? implode( ', ', CVSEO_Settings::conflicts() ) : '', 106 array_merge( 107 self::base_config(), 108 array( 109 'template' => $template, 110 'ptNoindex' => CVSEO_Settings::pt_noindex( $post->post_type ), 111 'isPage' => 'page' === $post->post_type, 112 'cats' => (object) $cats, 113 ) 79 114 ) 80 115 ); -
clearyvo-seo/tags/0.6.5/seo/assets/css/sidebar.css
r3787975 r3790680 66 66 .cvseo-sb .components-base-control { margin-bottom: 16px; } 67 67 .cvseo-sb .components-base-control:last-child { margin-bottom: 0; } 68 69 /* ---------------------------------------------------------------- 0.6.0: header button + snippet editor */ 70 71 /* Big "Edit SEO" button in the editor header: filled, in the admin colour like the editor's own buttons. */ 72 .cvseo-hb-slot { display: flex; align-items: center; margin-right: 4px; } 73 .cvseo-hb { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 5px 0 8px; border: 0; border-radius: 2px; cursor: pointer; 74 background: var(--wp-admin-theme-color, #3858e9); color: #fff; font: 500 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 75 white-space: nowrap; transition: background .15s; } 76 .cvseo-hb:hover { background: var(--wp-admin-theme-color-darker-10, #2145e6); color: #fff; } 77 .cvseo-hb:focus-visible { outline: 2px solid var(--wp-admin-theme-color, #3858e9); outline-offset: 2px; } 78 .cvseo-hb.is-open { background: var(--wp-admin-theme-color-darker-20, #183ad6); box-shadow: inset 0 -3px 0 rgba(255, 255, 255, .7); } 79 .cvseo-hb .cvseo-sb-logo { background: #fff; border-radius: 4px; } 80 .cvseo-hb-score { min-width: 26px; padding: 5px 7px; border-radius: 12px; font-size: 12px; font-weight: 600; text-align: center; color: #fff; } 81 .cvseo-hb-score.is-good { background: #00a32a; } .cvseo-hb-score.is-warn { background: #dba617; color: #1e1e1e; } .cvseo-hb-score.is-bad { background: #d63638; } 82 @media (max-width: 900px) { .cvseo-hb-label { display: none; } } 83 84 /* Clickable preview + button in the side panel. */ 85 .cvseo-sb-previewbtn { display: block; width: 100%; padding: 0; margin: 0; border: 0; background: none; text-align: left; cursor: pointer; } 86 .cvseo-sb-previewbtn .cvseo-sb-preview { margin-bottom: 8px; transition: border-color .15s, box-shadow .15s; } 87 .cvseo-sb-previewbtn:hover .cvseo-sb-preview, .cvseo-sb-previewbtn:focus-visible .cvseo-sb-preview { border-color: var(--wp-admin-theme-color, #3858e9); box-shadow: 0 0 0 1px var(--wp-admin-theme-color, #3858e9); } 88 .cvseo-sb .cvseo-sb-editbtn { width: 100%; justify-content: center; margin: 0 0 16px; } -
clearyvo-seo/tags/0.6.5/seo/assets/js/sidebar.js
r3787975 r3790680 1 1 /** 2 * Clearyvo SEO – block editor sidebar.2 * Clearyvo SEO – block editor panel. 3 3 * Live checks on the unsaved content, grouped by topic: what is good gets a tick, what is not good enough a warning. 4 * No 0–100 score: keyword stuffing turns the light red instead of green. 4 * The big "Edit SEO" button in the header opens this panel; the snippet editor opens from inside it. 5 * Helpers and the snippet editor live in snippet-core.js (window.cvseoSnippet). 5 6 */ 6 ( function ( wp, cfg ) {7 ( function ( wp, cfg, S ) { 7 8 'use strict'; 8 if ( ! wp || ! wp.plugins || ! cfg ) {9 if ( ! wp || ! wp.plugins || ! cfg || ! S ) { 9 10 return; 10 11 } 12 S.configure( cfg ); 11 13 var el = wp.element.createElement; 12 14 var Fragment = wp.element.Fragment; … … 22 24 23 25 var SIDEBAR = 'clearyvo-seo-sidebar'; 24 var TITLE_PX = 580; 25 var DESC_MAX = 160; 26 27 // ---------------------------------------------------------------- helpers 28 29 var canvas = document.createElement( 'canvas' ).getContext( '2d' ); 30 function px( text, font ) { 31 canvas.font = font; 32 return Math.round( canvas.measureText( text ).width ); 33 } 34 function esc( s ) { 35 return String( s ).replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); 36 } 37 // Word forms: every word of the phrase may take a suffix ("alkatrész" also matches "alkatrészek", "alkatrészt"), 38 // which matters for Hungarian and most other European languages. 39 function phraseRe( phrase ) { 40 return phrase.trim().split( /\s+/ ).map( function ( w ) { 41 return esc( w ) + '\\p{L}{0,6}'; 42 } ).join( '[\\s\\u00a0-]+' ); 43 } 44 function countPhrase( phrase, text ) { 45 phrase = ( phrase || '' ).trim(); 46 if ( ! phrase || ! text ) { 47 return 0; 48 } 49 try { 50 return ( text.match( new RegExp( '(?<![\\p{L}\\p{N}])' + phraseRe( phrase ) + '(?![\\p{L}\\p{N}])', 'giu' ) ) || [] ).length; 51 } catch ( e ) { 52 return text.toLowerCase().split( phrase.toLowerCase() ).length - 1; 53 } 54 } 55 function words( text ) { 56 try { 57 return ( text.match( /[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu ) || [] ).length; 58 } catch ( e ) { 59 return text.split( /\s+/ ).filter( Boolean ).length; 60 } 61 } 62 function slugify( s ) { 63 return String( s ).toLowerCase().normalize( 'NFD' ).replace( /[̀-ͯ]/g, '' ).replace( /[^a-z0-9]+/g, '-' ).replace( /^-|-$/g, '' ); 64 } 65 function render( tpl, title ) { 66 var vars = { 67 '%title%': title || '', 68 '%sitename%': cfg.sitename || '', 69 '%tagline%': cfg.tagline || '', 70 '%sep%': cfg.sep || '–', 71 '%year%': String( new Date().getFullYear() ) 72 }; 73 var out = String( tpl || '' ).replace( /%[a-z_]+%/gi, function ( m ) { 74 return Object.prototype.hasOwnProperty.call( vars, m ) ? vars[ m ] : ''; 75 } ).replace( /\s+/g, ' ' ); 76 var sep = esc( cfg.sep || '' ); 77 if ( sep ) { 78 out = out.replace( new RegExp( '(\\s*' + sep + '\\s*){2,}', 'g' ), ' ' + cfg.sep + ' ' ); 79 out = out.replace( new RegExp( '^\\s*' + sep + '\\s*|\\s*' + sep + '\\s*$', 'g' ), '' ); 80 } 81 return out.trim(); 82 } 83 function excerptOf( text ) { 84 text = text.replace( /\s+/g, ' ' ).trim(); 85 if ( text.length <= 155 ) { 86 return text; 87 } 88 var cut = text.slice( 0, 156 ); 89 var sp = cut.lastIndexOf( ' ' ); 90 return cut.slice( 0, sp > 0 ? sp : 155 ).replace( /[\s,;:–-]+$/, '' ) + '…'; 91 } 92 93 // ---------------------------------------------------------------- analysis 94 95 function parse( html ) { 96 var doc = new DOMParser().parseFromString( '<body>' + ( html || '' ) + '</body>', 'text/html' ); 97 var body = doc.body; 98 Array.prototype.forEach.call( body.querySelectorAll( 'script, style' ), function ( n ) { 99 n.remove(); 100 } ); 101 var blocks = []; 102 Array.prototype.forEach.call( body.querySelectorAll( 'p, li, blockquote, h1, h2, h3, h4, h5, h6, td, figcaption' ), function ( n ) { 103 blocks.push( n ); 104 } ); 105 var text = ( body.textContent || '' ).replace( /\s+/g, ' ' ).trim(); 106 return { 107 body: body, 108 text: text, 109 paragraphs: Array.prototype.map.call( body.querySelectorAll( 'p' ), function ( p ) { 110 return ( p.textContent || '' ).trim(); 111 } ).filter( Boolean ), 112 headings: Array.prototype.map.call( body.querySelectorAll( 'h1, h2, h3, h4, h5, h6' ), function ( h ) { 113 return { level: parseInt( h.tagName.slice( 1 ), 10 ), text: ( h.textContent || '' ).trim() }; 114 } ), 115 images: Array.prototype.map.call( body.querySelectorAll( 'img' ), function ( i ) { 116 return { alt: i.hasAttribute( 'alt' ) ? i.getAttribute( 'alt' ).trim() : null, src: i.getAttribute( 'src' ) || '' }; 117 } ), 118 links: Array.prototype.map.call( body.querySelectorAll( 'a[href]' ), function ( a ) { 119 return { href: a.getAttribute( 'href' ), text: ( a.textContent || '' ).trim(), hasImg: !! a.querySelector( 'img' ) }; 120 } ), 121 blocks: blocks.length 122 }; 123 } 124 125 function analyse( d ) { 126 var sections = []; 127 var c = d.content; 128 var kw = ( d.kw || '' ).trim(); 129 var wc = words( c.text ); 130 131 // Title & description. 132 var s1 = []; 133 var tpx = px( d.title, '20px Arial' ); 134 if ( ! d.title ) { 135 s1.push( [ 'bad', 'The page has no title.' ] ); 136 } else if ( tpx > TITLE_PX ) { 137 s1.push( [ 'warn', 'The title is ' + tpx + ' px wide; Google cuts it after about ' + TITLE_PX + ' px. Shorten it or move the important words to the front.' ] ); 138 } else if ( d.title.length < 25 ) { 139 s1.push( [ 'warn', 'The title is only ' + d.title.length + ' characters. Describe the page a little more.' ] ); 140 } else { 141 s1.push( [ 'good', 'Title length is good (' + d.title.length + ' characters, ' + tpx + ' px).' ] ); 142 } 143 if ( ! d.descRaw ) { 144 s1.push( [ d.desc ? 'info' : 'warn', d.desc ? 'No description written: Google gets the start of the text. A written one usually gets more clicks.' : 'No description, and there is no text to take one from.' ] ); 145 } else if ( d.desc.length > DESC_MAX ) { 146 s1.push( [ 'warn', 'The description is ' + d.desc.length + ' characters; aim for 120–160, Google cuts the rest.' ] ); 147 } else if ( d.desc.length < 70 ) { 148 s1.push( [ 'warn', 'The description is only ' + d.desc.length + ' characters; 120–160 uses the space Google gives.' ] ); 149 } else { 150 s1.push( [ 'good', 'Description length is good (' + d.desc.length + ' characters).' ] ); 151 } 152 sections.push( { id: 'meta', title: 'Title & description', checks: s1 } ); 153 154 // Focus phrase. 155 var s2 = []; 156 if ( ! kw ) { 157 s2.push( [ 'info', 'No focus phrase. It is optional: add one if this page should be found for a specific search.' ] ); 158 } else { 159 var inT = countPhrase( kw, d.title ); 160 s2.push( inT === 0 ? [ 'warn', 'The focus phrase is not in the SEO title.' ] : ( inT > 1 ? [ 'bad', 'The focus phrase is in the title ' + inT + ' times. Once is enough; repeating it looks spammy.' ] : [ 'good', 'The focus phrase is in the title, once.' ] ) ); 161 var inD = countPhrase( kw, d.desc ); 162 s2.push( inD === 0 ? [ 'info', 'The focus phrase is not in the description (fine if it reads better without).' ] : ( inD > 1 ? [ 'warn', 'The focus phrase is repeated in the description.' ] : [ 'good', 'The focus phrase is in the description.' ] ) ); 163 var ks = slugify( kw ); 164 s2.push( ks && ( d.slug || '' ).indexOf( ks ) !== -1 ? [ 'good', 'The focus phrase is in the address (URL).' ] : [ 'info', 'The focus phrase is not in the address. Change the address only on new pages, or add a redirect.' ] ); 165 var n = countPhrase( kw, c.text ); 166 if ( wc === 0 ) { 167 s2.push( [ 'warn', 'There is no text yet to check the focus phrase in.' ] ); 168 } else if ( n === 0 ) { 169 s2.push( [ 'warn', 'The focus phrase does not appear in the text.' ] ); 170 } else { 171 var start = c.text.split( /\s+/ ).slice( 0, Math.max( 60, Math.round( wc * 0.1 ) ) ).join( ' ' ); 172 s2.push( countPhrase( kw, start ) ? [ 'good', 'The focus phrase appears near the start of the text.' ] : [ 'warn', 'The focus phrase does not appear in the first part of the text.' ] ); 173 var density = n * words( kw ) / wc * 100; 174 if ( wc < 100 && ! ( n >= 3 && density > cfg.maxDensity ) ) { 175 s2.push( [ 'info', 'Used ' + n + ( n === 1 ? ' time' : ' times' ) + '. The text is still too short to judge whether the phrase is used naturally.' ] ); 176 } else if ( n >= 3 && density > cfg.maxDensity ) { 177 s2.push( [ 'bad', 'Over-optimised: the focus phrase is ' + density.toFixed( 1 ) + '% of the text (' + n + ' times). Write naturally and use other words for the same thing.' ] ); 178 } else { 179 s2.push( [ 'good', 'Natural use: ' + n + ( n === 1 ? ' time' : ' times' ) + ' in ' + wc + ' words (' + density.toFixed( 1 ) + '%).' ] ); 180 } 181 } 182 var subs = c.headings.filter( function ( h ) { 183 return h.level >= 2 && h.level <= 4; 184 } ); 185 if ( subs.length ) { 186 s2.push( subs.some( function ( h ) { 187 return countPhrase( kw, h.text ); 188 } ) ? [ 'good', 'The focus phrase is in a subheading.' ] : [ 'info', 'The focus phrase is in no subheading (H2–H4). One is enough, where it fits.' ] ); 189 } 190 if ( c.images.length ) { 191 var withKw = c.images.filter( function ( i ) { 192 return i.alt && countPhrase( kw, i.alt ); 193 } ).length; 194 if ( withKw === 0 ) { 195 s2.push( [ 'info', 'No image ALT text contains the focus phrase. Use it on one image where it describes the picture.' ] ); 196 } else if ( withKw >= 3 && withKw > c.images.length / 2 ) { 197 s2.push( [ 'warn', 'The focus phrase is in ' + withKw + ' image ALT texts. On every image it counts as keyword stuffing; describe the pictures instead.' ] ); 198 } else { 199 s2.push( [ 'good', 'The focus phrase is in an image ALT text.' ] ); 200 } 201 } 202 if ( d.others === null ) { 203 s2.push( [ 'info', 'Checking whether other pages use the same focus phrase…' ] ); 204 } else if ( d.others.length ) { 205 s2.push( [ 'warn', 'Other pages use the same focus phrase (' + d.others.map( function ( o ) { 206 return '“' + o.title + '”'; 207 } ).join( ', ' ) + '). They compete with each other in Google: give each page its own phrase.' ] ); 208 } else { 209 s2.push( [ 'good', 'No other page targets this focus phrase.' ] ); 210 } 211 } 212 sections.push( { id: 'kw', title: 'Focus phrase', checks: s2 } ); 213 214 // Content. 215 var s3 = []; 216 var minWords = cfg.isPage ? 150 : 300; 217 s3.push( wc >= minWords ? [ 'good', wc + ' words of text.' ] : [ cfg.isPage ? 'info' : 'warn', 'Only ' + wc + ' words. Pages that answer a question fully tend to rank better; aim for ' + minWords + '+ if the topic needs it.' ] ); 218 var h1 = c.headings.filter( function ( h ) { 219 return h.level === 1; 220 } ).length; 221 if ( h1 > 1 ) { 222 s3.push( [ 'warn', 'The text contains ' + h1 + ' H1 headings. A page should have one H1; make the others H2.' ] ); 223 } else if ( h1 === 1 ) { 224 s3.push( [ 'info', 'The text contains an H1. That is right if your theme does not show the page title as a heading (full-width templates, page builders); if it does, make this one H2. The site audit checks the real page.' ] ); 225 } else { 226 s3.push( [ 'good', 'No extra H1 in the text: the page title is the H1.' ] ); 227 } 228 var skipped = false; 229 var prev = 1; 230 c.headings.forEach( function ( h ) { 231 if ( h.level > prev + 1 ) { 232 skipped = true; 233 } 234 prev = h.level; 235 } ); 236 if ( c.headings.length ) { 237 s3.push( skipped ? [ 'warn', 'A heading level is skipped (for example H2 → H4). Screen readers and Google read the levels as the outline of the page.' ] : [ 'good', 'Heading levels follow each other in order.' ] ); 238 } else if ( wc > 300 ) { 239 s3.push( [ 'warn', 'No subheadings in ' + wc + ' words. Split the text with H2 headings.' ] ); 240 } 241 var longP = c.paragraphs.filter( function ( p ) { 242 return words( p ) > 150; 243 } ).length; 244 if ( c.paragraphs.length ) { 245 s3.push( longP ? [ 'warn', longP + ( longP === 1 ? ' paragraph is' : ' paragraphs are' ) + ' longer than 150 words. Shorter paragraphs are easier to read on a phone.' ] : [ 'good', 'Paragraphs are a readable length.' ] ); 246 } 247 var sentences = c.text.split( /(?<=[.!?…])\s+/ ).filter( function ( x ) { 248 return words( x ) > 0; 249 } ); 250 if ( sentences.length >= 5 ) { 251 var longS = sentences.filter( function ( x ) { 252 return words( x ) > 25; 253 } ).length; 254 var share = Math.round( longS / sentences.length * 100 ); 255 s3.push( share > 25 ? [ 'warn', share + '% of the sentences are longer than 25 words. Break some of them up.' ] : [ 'good', 'Sentence length is easy to read.' ] ); 256 } 257 sections.push( { id: 'content', title: 'Content', checks: s3 } ); 258 259 // Images. 260 var s4 = []; 261 if ( ! c.images.length ) { 262 s4.push( [ 'info', 'No images in the text. A photo of your work or a diagram helps readers and can bring visitors from Google Images.' ] ); 263 } else { 264 var missing = c.images.filter( function ( i ) { 265 return ! i.alt; 266 } ).length; 267 if ( missing ) { 268 s4.push( [ 'warn', missing + ' of ' + c.images.length + ( c.images.length === 1 ? ' image has' : ' images have' ) + ' no ALT text. The Image ALT tab writes a draft for each from this page.', 'alt' ] ); 269 if ( ! cfg.pro ) { 270 s4.push( [ 'pro', 'Clearyvo SEO Pro writes all missing ALT texts with AI (your own Claude or OpenAI key), and every new upload gets one.' ] ); 271 } 272 } else { 273 s4.push( [ 'good', 'All ' + c.images.length + ( c.images.length === 1 ? ' image has' : ' images have' ) + ' ALT text.' ] ); 274 } 275 var alts = c.images.map( function ( i ) { 276 return ( i.alt || '' ).toLowerCase(); 277 } ).filter( Boolean ); 278 var dupes = alts.length - alts.filter( function ( a, i ) { 279 return alts.indexOf( a ) === i; 280 } ).length; 281 if ( alts.length > 1 ) { 282 s4.push( dupes ? [ 'warn', dupes + ' image ALT ' + ( dupes === 1 ? 'text repeats' : 'texts repeat' ) + ' another one. Each ALT should describe its own picture.' ] : [ 'good', 'Every image has its own ALT text.' ] ); 283 } 284 } 285 sections.push( { id: 'images', title: 'Images', checks: s4, extra: 'images' } ); 286 287 // Links. 288 var s5 = []; 289 var internal = 0; 290 var external = 0; 291 var empty = 0; 292 c.links.forEach( function ( l ) { 293 if ( ! l.text && ! l.hasImg ) { 294 empty++; 295 } 296 if ( /^(mailto|tel|#)/.test( l.href ) ) { 297 return; 298 } 299 try { 300 var u = new URL( l.href, cfg.home ); 301 if ( u.host === cfg.homeHost ) { 302 internal++; 303 } else { 304 external++; 305 } 306 } catch ( e ) {} 307 } ); 308 s5.push( internal ? [ 'good', internal + ' link' + ( internal === 1 ? '' : 's' ) + ' to other pages of the site.' ] : [ wc >= 300 ? 'warn' : 'info', 'No links to other pages of the site. Link to related pages: it helps visitors and Google find them.' ] ); 309 s5.push( external ? [ 'good', external + ' link' + ( external === 1 ? '' : 's' ) + ' to other websites.' ] : [ 'info', 'No links to other websites. Link to a source or standard where it helps the reader.' ] ); 310 if ( empty ) { 311 s5.push( [ 'warn', empty + ' link' + ( empty === 1 ? ' has' : 's have' ) + ' no text. Screen readers and Google cannot tell where ' + ( empty === 1 ? 'it leads' : 'they lead' ) + '.' ] ); 312 } 313 sections.push( { id: 'links', title: 'Links', checks: s5 } ); 314 315 // Indexing. 316 var s6 = []; 317 if ( ! cfg.blogPublic ) { 318 s6.push( [ 'bad', 'The whole site is hidden from search engines (Settings → Reading).' ] ); 319 } 320 var noindex = d.noindex === '1' || ( d.noindex === '' && cfg.ptNoindex ); 321 s6.push( noindex ? [ 'warn', 'This page is set to noindex: it will not appear in Google.' ] : [ 'good', 'Search engines may show this page.' ] ); 322 if ( d.canonical && d.link && d.canonical.replace( /\/$/, '' ) !== d.link.replace( /\/$/, '' ) ) { 323 s6.push( [ 'info', 'The canonical URL points to another address, so Google shows that one instead of this page.' ] ); 324 } 325 if ( d.nofollow ) { 326 s6.push( [ 'info', 'nofollow is on: Google does not follow the links of this page.' ] ); 327 } 328 if ( cfg.conflict ) { 329 s6.push( [ 'info', cfg.conflict + ' is still active, so it prints the SEO tags. Import its data in SEO → Import, then deactivate it.' ] ); 330 } 331 sections.push( { id: 'index', title: 'Indexing', checks: s6 } ); 332 333 // Score 0–100: every check that is not just a tip counts, weighted by its topic. A red problem (stuffing, 334 // phrase repeated in the title, hidden site) caps the score at 49, so over-optimising never shows green. 335 var weight = { meta: 3, kw: 2, content: 1.5, images: 2, links: 1, index: 2 }; 336 var got = 0; 337 var max = 0; 338 sections.forEach( function ( s ) { 339 s.checks.forEach( function ( k ) { 340 var w = weight[ s.id ] || 1; 341 if ( k[ 0 ] === 'good' || k[ 0 ] === 'warn' || k[ 0 ] === 'bad' ) { 342 max += w; 343 got += k[ 0 ] === 'good' ? w : ( k[ 0 ] === 'warn' ? w * 0.4 : 0 ); 344 } 345 } ); 346 } ); 347 var count = { bad: 0, warn: 0, good: 0 }; 348 sections.forEach( function ( s ) { 349 s.checks.forEach( function ( k ) { 350 if ( count[ k[ 0 ] ] !== undefined ) { 351 count[ k[ 0 ] ]++; 352 } 353 } ); 354 } ); 355 var score = max ? Math.round( got / max * 100 ) : 0; 356 if ( count.bad ) { 357 score = Math.min( score, 49 ); 358 } 359 return { sections: sections, count: count, score: score, status: score >= 80 ? 'good' : ( score >= 50 ? 'warn' : 'bad' ) }; 360 } 361 362 // ---------------------------------------------------------------- UI 363 364 var COLORS = { good: '#00a32a', warn: '#dba617', bad: '#d63638' }; 365 366 function Logo( props ) { 367 return el( 'img', { src: cfg.logo, width: props.size || 20, height: props.size || 20, alt: '', className: 'cvseo-sb-logo' } ); 368 } 369 370 /** Toolbar button: Clearyvo SEO logo and the score in a coloured pill. */ 371 function ToolbarIcon( props ) { 372 var ref = wp.element.useRef( null ); 373 // The editor sizes toolbar icon buttons as fixed squares (width, max-width or flex-basis, depending on the 374 // WordPress version). Inline styles with !important on the button itself win over every stylesheet. 375 useEffect( function () { 376 var btn = ref.current && ref.current.closest( 'button' ); 377 if ( ! btn ) { 378 return; 379 } 380 [ [ 'width', 'auto' ], [ 'min-width', '0' ], [ 'max-width', 'none' ], [ 'flex', '0 0 auto' ], [ 'padding', '0 6px' ], [ 'overflow', 'visible' ] ].forEach( function ( r ) { 381 btn.style.setProperty( r[ 0 ], r[ 1 ], 'important' ); 382 } ); 383 } ); 384 return el( 'span', { className: 'cvseo-tb', ref: ref }, 385 el( Logo, { size: 20 } ), 386 el( 'span', { className: 'cvseo-tb-score is-' + props.status }, 'SEO ' + props.score ) 387 ); 388 } 389 390 /** Score ring at the top of the sidebar. */ 391 function Ring( props ) { 392 var r = 26; 393 var len = 2 * Math.PI * r; 394 return el( 'svg', { width: 64, height: 64, viewBox: '0 0 64 64', className: 'cvseo-sb-ring', 'aria-hidden': true }, 395 el( 'circle', { cx: 32, cy: 32, r: r, fill: 'none', stroke: '#e0e0e0', strokeWidth: 6 } ), 396 el( 'circle', { cx: 32, cy: 32, r: r, fill: 'none', stroke: COLORS[ props.status ], strokeWidth: 6, strokeLinecap: 'round', 397 strokeDasharray: len, strokeDashoffset: len * ( 1 - props.score / 100 ), transform: 'rotate(-90 32 32)' } ), 398 el( 'text', { x: 32, y: 37, textAnchor: 'middle', fontSize: 17, fontWeight: 700, fill: '#1e1e1e' }, String( props.score ) ) 399 ); 400 } 401 402 function Check( props ) { 403 var k = props.check; 404 var mark = { good: '✓', warn: '!', bad: '✕', info: 'i', pro: 'Pro' }[ k[ 0 ] ]; 405 var extra = null; 406 if ( k[ 2 ] === 'alt' && props.title ) { 407 extra = el( 'a', { href: cfg.auditUrl + encodeURIComponent( props.title ), target: '_blank', rel: 'noopener noreferrer', className: 'cvseo-sb-link' }, 'Open the ALT drafts ↗' ); 408 } 409 if ( k[ 0 ] === 'pro' ) { 410 extra = el( 'a', { href: cfg.proUrl, target: '_blank', rel: 'noopener noreferrer', className: 'cvseo-sb-link' }, 'See Pro ↗' ); 411 } 412 return el( 'li', { className: 'cvseo-sb-check is-' + k[ 0 ] }, 413 el( 'span', { className: 'cvseo-sb-mark', 'aria-label': k[ 0 ] }, mark ), 414 el( 'span', { className: 'cvseo-sb-text' }, k[ 1 ], extra ? ' ' : null, extra ) 415 ); 416 } 417 418 function sectionBadge( checks ) { 419 var bad = checks.filter( function ( k ) { 420 return k[ 0 ] === 'bad'; 421 } ).length; 422 var warn = checks.filter( function ( k ) { 423 return k[ 0 ] === 'warn'; 424 } ).length; 425 var good = checks.filter( function ( k ) { 426 return k[ 0 ] === 'good'; 427 } ).length; 428 if ( bad ) { 429 return { cls: 'bad', text: bad + ' problem' + ( bad > 1 ? 's' : '' ) }; 430 } 431 if ( warn ) { 432 return { cls: 'warn', text: warn + ' to improve' }; 433 } 434 return { cls: good ? 'good' : 'info', text: good ? 'All good' : 'Tips' }; 435 } 436 437 function Preview( props ) { 438 var title = props.title; 439 if ( px( title, '20px Arial' ) > TITLE_PX ) { 440 while ( title.length && px( title + ' …', '20px Arial' ) > TITLE_PX ) { 441 title = title.slice( 0, -1 ); 442 } 443 title = title.trim() + ' …'; 444 } 445 var desc = props.desc.length > 158 ? props.desc.slice( 0, 155 ).trim() + ' …' : props.desc; 446 var url = String( props.link || cfg.home ).replace( /^https?:\/\//, '' ).replace( /\/$/, '' ).split( '/' ).join( ' › ' ); 447 return el( 'div', { className: 'cvseo-sb-preview', 'aria-label': 'Google preview' }, 448 el( 'div', { className: 'cvseo-sb-p-url' }, url ), 449 el( 'div', { className: 'cvseo-sb-p-title' }, title || '(no title)' ), 450 el( 'div', { className: 'cvseo-sb-p-desc' }, desc ) 451 ); 452 } 453 454 function Meter( props ) { 455 var r = props.value / props.max; 456 var cls = r > 1 ? 'bad' : ( r < props.low ? 'warn' : 'good' ); 457 return el( 'div', { className: 'cvseo-sb-meter' }, el( 'span', { className: 'is-' + cls, style: { width: Math.min( 100, r * 100 ) + '%' } } ) ); 458 } 459 460 function useDebounced( value, ms ) { 461 var st = useState( value ); 462 useEffect( function () { 463 var t = setTimeout( function () { 464 st[ 1 ]( value ); 465 }, ms ); 466 return function () { 467 clearTimeout( t ); 468 }; 469 }, [ value ] ); 470 return st[ 0 ]; 471 } 26 var TITLE_PX = S.TITLE_PX; 27 var DESC_MAX = S.DESC_MAX; 28 var px = S.px, render = S.render, excerptOf = S.excerptOf, parse = S.parse, analyse = S.analyse, useDebounced = S.useDebounced; 29 var Logo = S.Logo, ToolbarIcon = S.ToolbarIcon, Ring = S.Ring, Check = S.Check, sectionBadge = S.sectionBadge, Preview = S.Preview, Meter = S.Meter; 30 var SnippetEditor = S.SnippetEditor, useHeaderSlot = S.useHeaderSlot, HeaderButton = S.HeaderButton; 472 31 473 32 function Sidebar() { … … 482 41 link: ed.getPermalink ? ed.getPermalink() : '', 483 42 meta: ed.getEditedPostAttribute( 'meta' ) || {}, 484 cats: ed.getEditedPostAttribute( 'categories' ) || [] 43 cats: ed.getEditedPostAttribute( 'categories' ) || [], 44 date: ed.getEditedPostAttribute( 'date' ) || '', 45 author: ed.getEditedPostAttribute( 'author' ) || 0, 46 featured: ed.getEditedPostAttribute( 'featured_media' ) || 0 485 47 }; 48 }, [] ); 49 var extra = useSelect( function ( select ) { 50 var core = select( 'core' ); 51 var media = data.featured && core && core.getMedia ? core.getMedia( data.featured ) : null; 52 var user = data.author && core && core.getUser ? core.getUser( data.author ) : null; 53 return { featured: media && media.source_url ? media.source_url : '', author: user && user.name ? user.name : '' }; 54 }, [ data.featured, data.author ] ); 55 var modalSt = useState( false ); 56 var slot = useHeaderSlot(); 57 var panel = usePanel(); 58 // Opened from a Clearyvo SEO view (?cvseo_from=tab): the editor's back button returns to that view, not to the 59 // list of pages. The parameter stays in the address, so a reload keeps it. 60 useEffect( function () { 61 var m = window.location.search.match( /[?&]cvseo_from=([a-z_]+)/ ); 62 if ( ! m || ! cfg.backUrl ) { 63 return; 64 } 65 var back = cfg.backUrl + '&tab=' + m[ 1 ]; 66 // The back button differs between WordPress versions (W logo, site icon, "<" arrow, its class names), but it 67 // is always a link in the editor header to the list of posts or pages (edit.php). Catch that link, plus 68 // the known class names in case a version renders it as a button. 69 var HEADER = '.editor-header, .edit-post-header, .interface-interface-skeleton__header'; 70 var CLOSE = '.editor-fullscreen-mode-close, .edit-post-fullscreen-mode-close, [class*="fullscreen-mode-close"], [class*="header__back"]'; 71 function isBack( node ) { 72 if ( ! node || ! node.closest || ! node.closest( HEADER ) ) { 73 return false; 74 } 75 if ( node.matches( CLOSE ) ) { 76 return true; 77 } 78 // A back button without a link (rendered as a button): known labels, in English and Hungarian. 79 var label = ( node.getAttribute( 'aria-label' ) || node.getAttribute( 'title' ) || '' ).toLowerCase(); 80 if ( node.tagName === 'BUTTON' && /^(back|go back|vissza|view (posts|pages)|(all )?(posts|pages)|dashboard|bejegyzések|oldalak|vezérlőpult)/.test( label ) ) { 81 return true; 82 } 83 var href = node.getAttribute( 'href' ) || ''; 84 return node.tagName === 'A' && /(^|\/)edit\.php(\?|$)/.test( href.replace( /^https?:\/\/[^/]+/, '' ).replace( /^.*\/wp-admin\//, '' ) ); 85 } 86 function backNode( target ) { 87 var n = target && target.closest ? target.closest( 'a, button' ) : null; 88 return isBack( n ) ? n : null; 89 } 90 function onClick( e ) { 91 if ( backNode( e.target ) ) { 92 e.preventDefault(); 93 e.stopPropagation(); 94 window.location.href = back; 95 } 96 } 97 function relabel() { 98 document.querySelectorAll( HEADER ).forEach( function ( h ) { 99 h.querySelectorAll( 'a, button' ).forEach( function ( a ) { 100 if ( isBack( a ) && a.tagName === 'A' && a.getAttribute( 'href' ) !== back ) { 101 a.setAttribute( 'href', back ); 102 a.setAttribute( 'aria-label', 'Back to Clearyvo SEO' ); 103 } 104 } ); 105 } ); 106 } 107 document.addEventListener( 'click', onClick, true ); 108 relabel(); 109 var t = setInterval( relabel, 1500 ); 110 return function () { 111 document.removeEventListener( 'click', onClick, true ); 112 clearInterval( t ); 113 }; 114 }, [] ); 115 // Opened from the "Visual editor" buttons of the Clearyvo SEO screens (?cvseo=panel): show the panel once. 116 useEffect( function () { 117 if ( /[?&]cvseo=panel(&|$)/.test( window.location.search ) ) { 118 setTimeout( function () { 119 if ( ! panel.open ) { 120 panel.toggle(); 121 } 122 }, 300 ); 123 try { 124 window.history.replaceState( null, '', window.location.href.replace( /([?&])cvseo=panel(&|$)/, function ( m, a, b ) { 125 return b ? a : ''; 126 } ) ); 127 } catch ( e ) {} 128 } 486 129 }, [] ); 487 130 var editPost = useDispatch( 'core/editor' ).editPost; … … 519 162 return parse( debContent ); 520 163 }, [ debContent ] ); 164 165 var primary = parseInt( get( 'primary_cat' ), 10 ) || 0; 166 var catId = primary && data.cats.indexOf( primary ) !== -1 ? primary : data.cats[ 0 ]; 167 var dateText = ''; 168 if ( data.date && wp.date && wp.date.dateI18n ) { 169 try { 170 dateText = wp.date.dateI18n( wp.date.getSettings().formats.date, data.date ); 171 } catch ( e ) { 172 dateText = ''; 173 } 174 } 175 S.setLiveVars( { 176 excerpt: excerptOf( data.excerpt ? data.excerpt.replace( /<[^>]+>/g, '' ) : parsed.text ), 177 category: catId && cfg.cats[ catId ] ? cfg.cats[ catId ] : '', 178 author: extra.author, 179 date: dateText 180 } ); 521 181 522 182 var title = render( get( 'title' ) || cfg.template || '%title%', data.postTitle ); … … 546 206 547 207 var fields = el( C.PanelBody, { title: 'Search appearance', initialOpen: true }, 548 el( Preview, { title: title, desc: desc, link: data.link } ), 208 el( 'button', { type: 'button', className: 'cvseo-sb-previewbtn', title: 'Open the snippet editor', onClick: function () { 209 modalSt[ 1 ]( true ); 210 } }, el( Preview, { title: title, desc: desc, link: data.link } ) ), 211 el( C.Button, { variant: 'primary', className: 'cvseo-sb-editbtn', onClick: function () { 212 modalSt[ 1 ]( true ); 213 } }, 'Open the snippet editor' ), 549 214 el( 'div', { className: 'cvseo-sb-kwhead' }, 550 215 el( 'label', { htmlFor: 'cvseo-sb-kw' }, 'Focus phrase' ), … … 639 304 return el( Fragment, null, 640 305 el( PluginSidebarMoreMenuItem, { target: SIDEBAR, icon: el( Logo, { size: 20 } ) }, 'Clearyvo SEO' ), 641 el( PluginSidebar, { name: SIDEBAR, title: 'Clearyvo SEO', icon: el( ToolbarIcon, { status: result.status, score: result.score } ), className: 'cvseo-sb' }, 306 // The big "Edit SEO" header button opens this panel, so no second (pinned) icon on the toolbar. Only if the 307 // header button cannot be placed (unknown editor layout) the panel keeps its pin as the way in. 308 el( PluginSidebar, { name: SIDEBAR, title: 'Clearyvo SEO', icon: el( ToolbarIcon, { status: result.status, score: result.score } ), className: 'cvseo-sb', isPinnable: ! slot }, 642 309 summary, fields, panels, social, advanced 643 ) 310 ), 311 slot && wp.element.createPortal ? wp.element.createPortal( el( HeaderButton, { status: result.status, score: result.score, open: panel.open, onClick: panel.toggle } ), slot ) : null, 312 modalSt[ 0 ] ? el( SnippetEditor, { 313 mode: 'editor', 314 close: function () { 315 modalSt[ 1 ]( false ); 316 }, 317 get: get, set: set, title: title, desc: desc, link: data.link, slug: data.slug, 318 setSlug: function ( v ) { 319 editPost( { slug: v } ); 320 }, 321 postId: data.id, postTitle: data.postTitle, featured: extra.featured, score: result.score, status: result.status, 322 date: cfg.isPage ? '' : dateText, content: parsed 323 } ) : null 644 324 ); 645 325 } 646 326 327 /** Open / close state of the Clearyvo SEO panel (interface store; scope "core" since WordPress 6.6). */ 328 function usePanel() { 329 var id = 'clearyvo-seo/' + SIDEBAR; 330 var scope = wp.editor && wp.editor.PluginSidebar ? 'core' : 'core/edit-post'; 331 var active = useSelect( function ( select ) { 332 var s = select( 'core/interface' ); 333 return s && s.getActiveComplementaryArea ? s.getActiveComplementaryArea( scope ) : null; 334 }, [] ); 335 var open = active === id; 336 return { 337 open: open, 338 toggle: function () { 339 var d = wp.data.dispatch( 'core/interface' ); 340 var ep = wp.data.dispatch( 'core/edit-post' ); 341 if ( open ) { 342 if ( d && d.disableComplementaryArea ) { 343 d.disableComplementaryArea( scope ); 344 } else if ( ep && ep.closeGeneralSidebar ) { 345 ep.closeGeneralSidebar(); 346 } 347 } else if ( d && d.enableComplementaryArea ) { 348 d.enableComplementaryArea( scope, id ); 349 } else if ( ep && ep.openGeneralSidebar ) { 350 ep.openGeneralSidebar( id ); 351 } 352 } 353 }; 354 } 355 356 /** 357 * Opened from a Clearyvo SEO view (?cvseo_from=tab): the editor's own back button is replaced, through the 358 * MainDashboardButton slot of the editor, with one that goes back to that view. Older and newer editors without 359 * the slot are covered by the click handler in Sidebar. 360 */ 361 ( function () { 362 var m = window.location.search.match( /[?&]cvseo_from=([a-z_]+)/ ); 363 var ns = [ wp.editor, wp.editPost ].filter( function ( n ) { 364 return n && n.__experimentalMainDashboardButton; 365 } )[ 0 ]; 366 if ( ! m || ! cfg.backUrl || ! ns ) { 367 return; 368 } 369 var back = cfg.backUrl + '&tab=' + m[ 1 ]; 370 var Close = ( wp.editor && wp.editor.__experimentalFullscreenModeClose ) || ( wp.editPost && wp.editPost.__experimentalFullscreenModeClose ); 371 wp.plugins.registerPlugin( 'clearyvo-seo-back', { 372 render: function () { 373 return el( ns.__experimentalMainDashboardButton, null, 374 Close ? el( Close, { href: back, showTooltip: true } ) : 375 el( C.Button, { href: back, icon: 'arrow-left-alt2', label: 'Back to Clearyvo SEO', className: 'cvseo-back' } ) 376 ); 377 } 378 } ); 379 }() ); 380 647 381 wp.plugins.registerPlugin( 'clearyvo-seo', { render: Sidebar } ); 648 }( window.wp, window.cvseoSidebar ) );382 }( window.wp, window.cvseoSidebar, window.cvseoSnippet ) ); -
clearyvo-seo/tags/0.6.5/seo/boot.php
r3788085 r3790680 21 21 return; 22 22 } 23 define( 'CVSEO_VERSION', '0. 5.3' );23 define( 'CVSEO_VERSION', '0.6.5' ); 24 24 define( 'CVSEO_DB_VERSION', '1' ); 25 25 define( 'CVSEO_FILE', $base_file ); … … 40 40 require_once CVSEO_DIR . 'admin/class-cvseo-metabox.php'; 41 41 require_once CVSEO_DIR . 'admin/class-cvseo-sidebar.php'; 42 require_once CVSEO_DIR . 'admin/class-cvseo-visual.php'; 42 43 } 43 44 … … 59 60 CVSEO_Metabox::init(); 60 61 CVSEO_Sidebar::init(); 62 CVSEO_Visual::init(); 61 63 } 62 64 }; -
clearyvo-seo/tags/0.6.5/seo/includes/class-cvseo-rest.php
r3788085 r3790680 83 83 return current_user_can( 'edit_post', (int) $req['id'] ); 84 84 }, 85 ), 86 ) 87 ); 88 // Admin "Visual editor": page picker; the chosen page opens in the page editor with the Clearyvo SEO panel. 89 register_rest_route( 90 self::NS, 91 '/snippet-search', 92 array( 93 'methods' => 'GET', 94 'callback' => array( __CLASS__, 'search_snippets' ), 95 'permission_callback' => static function () { 96 return current_user_can( 'edit_posts' ); 97 }, 98 'args' => array( 99 's' => array( 100 'type' => 'string', 101 'default' => '', 102 ), 85 103 ), 86 104 ) … … 277 295 278 296 /** 297 * GET /snippet-search?s=: pages the user can edit, newest change first. 298 * 299 * @param WP_REST_Request $req Request. 300 * @return WP_REST_Response 301 */ 302 public static function search_snippets( WP_REST_Request $req ) { 303 $q = new WP_Query( 304 array( 305 'post_type' => CVSEO_Settings::post_types(), 306 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 307 's' => sanitize_text_field( (string) $req['s'] ), 308 'posts_per_page' => 30, 309 'orderby' => '' === (string) $req['s'] ? 'modified' : 'relevance', 310 'order' => 'DESC', 311 'no_found_rows' => true, 312 'update_post_meta_cache' => false, 313 'update_post_term_cache' => false, 314 ) 315 ); 316 $out = array(); 317 foreach ( $q->posts as $p ) { 318 if ( ! current_user_can( 'edit_post', $p->ID ) ) { 319 continue; 320 } 321 $pto = get_post_type_object( $p->post_type ); 322 $out[] = array( 323 'id' => $p->ID, 324 'title' => '' !== $p->post_title ? $p->post_title : __( '(no title)', 'clearyvo-seo' ), 325 'type' => $pto ? $pto->labels->singular_name : $p->post_type, 326 'status' => 'publish' === $p->post_status ? '' : $p->post_status, 327 'edit' => add_query_arg( 'cvseo', 'panel', get_edit_post_link( $p->ID, 'raw' ) ), 328 ); 329 } 330 return rest_ensure_response( array( 'items' => $out ) ); 331 } 332 333 /** 279 334 * GET /post/{id}. 280 335 * -
clearyvo-seo/trunk/assets/style.css
r3787975 r3790680 76 76 .wpsc-table td{vertical-align:top} 77 77 @media (max-width:782px){.wpsc-explain{grid-template-columns:1fr}} 78 79 /* 0.6.3: what the last scan found on this page (Image ALT view). */ 80 .wpsc-scanflag { margin: 6px 0 2px; font-size: 13px; color: #8a4b00; } 81 .wpsc-scanflag-badge { display: inline-block; padding: 1px 8px; margin-right: 4px; border-radius: 10px; background: #fcf0e3; border: 1px solid #f0c48a; color: #8a4b00; font-size: 11.5px; font-weight: 600; } -
clearyvo-seo/trunk/clearyvo-seo.php
r3788085 r3790680 4 4 * Plugin URI: https://wp-sitechecker.com/ 5 5 * Description: SEO without keyword stuffing: real image ALT text from page context, not filenames. Titles, meta, schema, sitemap, redirects, 404 log, a full site audit and one-click import from Yoast SEO and Rank Math. 6 * Version: 0. 5.36 * Version: 0.6.5 7 7 * Requires at least: 6.2 8 8 * Requires PHP: 7.4 -
clearyvo-seo/trunk/includes/class-wpsc-admin.php
r3787975 r3790680 37 37 'alt_part' => $part($gap, $img), 'h_part' => $part($hp, $np), 'm_part' => $part($mp, $np), 38 38 'site_h1' => count($h['site']), 'conflicts' => count($k['conflicts']), 'orphans' => count($l['orphans']), 'menu_only' => count($l['menuOnly']), 39 'alt_rows' => count(WPSC_Audit::alt_problems()), 39 40 'headings_rows' => $hrows + count($h['site']), 'keywords_rows' => $krows, 'meta_rows' => $mrows, 'links_rows' => count($l['orphans']) + count($l['menuOnly']), 40 41 ]; … … 283 284 $type = WPSiteChecker_UI::filter_value('type', array_keys($types)); 284 285 $s = WPSiteChecker_UI::filter_value('s'); 286 // Pages where the last scan found images without ALT (the Overview card and "Show and fix" open this filter). 287 $probs = WPSC_Audit::state() && !empty(WPSC_Audit::state()['finished']) ? WPSC_Audit::alt_problems() : []; 288 $scan = WPSiteChecker_UI::filter_value('scan', ['noalt']); 285 289 $paged = WPSiteChecker_UI::current_page_num(); 286 $q = new WP_Query(['post_type' => $type !== '' ? $type : array_keys($types), 'post_status' => 'publish', 'posts_per_page' => self::PER_PAGE, 'paged' => $paged, 287 's' => $s, 'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'], 'ignore_sticky_posts' => true, 'has_password' => false, 'no_found_rows' => false]); 290 $args = ['post_type' => $type !== '' ? $type : array_keys($types), 'post_status' => 'publish', 'posts_per_page' => self::PER_PAGE, 'paged' => $paged, 291 's' => $s, 'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'], 'ignore_sticky_posts' => true, 'has_password' => false, 'no_found_rows' => false]; 292 if ($scan === 'noalt') $args['post__in'] = $probs ? array_keys($probs) : [0]; 293 $q = new WP_Query($args); 288 294 289 295 WPSC_Views::explain(__('Checks every published page as visitors and Google see it: which images have no ALT text, whether an image ALT contains the page\'s focus keyword, and which ALT texts repeat. Nothing changes until you save an ALT yourself.', 'clearyvo-seo'), … … 307 313 WPSiteChecker_UI::filter_bar([ 308 314 'label' => __('Filter pages', 'clearyvo-seo'), 309 'filters' => [['name' => 'type', 'label' => __('Content type', 'clearyvo-seo'), 'options' => $topts, 'value' => $type]], 315 'filters' => [['name' => 'type', 'label' => __('Content type', 'clearyvo-seo'), 'options' => $topts, 'value' => $type], 316 /* translators: %s: number of pages */ 317 ['name' => 'scan', 'label' => __('Last scan', 'clearyvo-seo'), 'options' => ['' => __('All pages', 'clearyvo-seo'), 'noalt' => sprintf(__('Missing ALT in the last scan (%s)', 'clearyvo-seo'), WPSiteChecker_Family::num(count($probs)))], 'value' => $scan]], 310 318 // Checked in the browser by app.js (the results of each page are computed there): a local checkbox. 311 319 'checks' => [['id' => 'wpsc-only-problems', 'label' => __('Show only pages with problems', 'clearyvo-seo'), 'local' => true]], … … 314 322 echo '<p id="wpsc-summary" class="wpsc-summary" role="status" aria-live="polite"></p><div id="wpsc-sitewide"></div>'; 315 323 if (!$q->have_posts()) { 316 if ($type !== '' || $s !== '' ) WPSiteChecker_UI::empty_filtered(WPSiteChecker_UI::clear_filters_url(['type', 's']));324 if ($type !== '' || $s !== '' || $scan !== '') WPSiteChecker_UI::empty_filtered(WPSiteChecker_UI::clear_filters_url(['type', 's', 'scan'])); 317 325 else WPSiteChecker_UI::empty_state(['title' => __('No published pages', 'clearyvo-seo'), 'text' => __('Publish a page or post: its images are checked here.', 'clearyvo-seo')]); 318 326 return; … … 330 338 . '<div class="wpsc-pagehead"><div><h2>' . esc_html($title) . ' <span class="wpsc-type">' . esc_html($type_obj ? $type_obj->labels->singular_name : $post->post_type) . '</span></h2>' 331 339 . '<div class="wpsc-meta"><strong>' . esc_html__('Focus keyword:', 'clearyvo-seo') . '</strong> ' . ($kw ? esc_html(implode(' · ', $kw)) : '<em>' . esc_html__('not set', 'clearyvo-seo') . '</em>') . '</div>' 340 . (isset($probs[$post->ID]) ? '<div class="wpsc-scanflag"><span class="wpsc-scanflag-badge">' . esc_html__('Last scan', 'clearyvo-seo') . '</span> ' . esc_html(self::alt_flag($probs[$post->ID])) . '</div>' : '') 332 341 . '<div class="wpsc-checks"></div></div>' 333 342 . '<div class="wpsc-headlinks"><a class="button" target="_blank" rel="noopener" href="' . esc_url(get_permalink($post)) . '">' . esc_html__('View page', 'clearyvo-seo') . $nt . '</a>' … … 337 346 wp_reset_postdata(); 338 347 WPSiteChecker_UI::pagination($pager + ['which' => 'bottom']); 348 } 349 350 /** "1 image without ALT, 2 with empty ALT" for one page of the last scan. */ 351 private static function alt_flag(array $p) { 352 $parts = []; 353 /* translators: %s: number of images */ 354 if ($p[0]) $parts[] = sprintf(_n('%s image without ALT', '%s images without ALT', $p[0], 'clearyvo-seo'), WPSiteChecker_Family::num($p[0])); 355 /* translators: %s: number of images */ 356 if ($p[1]) $parts[] = sprintf(_n('%s image with empty ALT', '%s images with empty ALT', $p[1], 'clearyvo-seo'), WPSiteChecker_Family::num($p[1])); 357 return implode(', ', $parts) . ' ' . __('(as the page is shown; the ALT written in the page or by the theme counts, not the Media Library)', 'clearyvo-seo'); 339 358 } 340 359 -
clearyvo-seo/trunk/includes/class-wpsc-audit.php
r3787975 r3790680 71 71 static function flush() { self::$cache = null; } 72 72 73 /** Pages where the last scan found content images without ALT or with an empty ALT: id => [no ALT, empty ALT]. */ 74 static function alt_problems() { 75 $out = []; 76 foreach (self::pages() as $id => $d) { 77 $im = (array) ($d['imgs'] ?? []); 78 $no = (int) ($im[1] ?? 0); $empty = (int) ($im[2] ?? 0); 79 if ($no + $empty) $out[(int) $id] = [$no, $empty]; 80 } 81 return $out; 82 } 83 73 84 static function lc($s) { return function_exists('mb_strtolower') ? mb_strtolower((string) $s, 'UTF-8') : strtolower((string) $s); } 74 85 /** Lower case, accents removed, single spaces: "Generálkivitelezés Budapest" == "generalkivitelezes budapest". */ -
clearyvo-seo/trunk/includes/class-wpsc-views.php
r3787975 r3790680 4 4 /** 5 5 * Overview and the views that read the site scan (family UI kit). Hooks for add-ons (Clearyvo SEO Pro): 6 * wpsc_overview_cards, wpsc_keywords_before_table, wpsc_orphan_actions, wpsc_links_after_orphans, wpsc_schema_section. 6 * wpsc_overview_cards, wpsc_keywords_before_table, wpsc_meta_before_table, wpsc_orphan_actions, wpsc_links_after_orphans, 7 * wpsc_schema_section. 7 8 */ 8 9 final class WPSC_Views { … … 15 16 $h = '<a href="' . esc_url(self::page_url($id)) . '" target="_blank" rel="noopener">' . esc_html(self::title($id)) . '<span class="screen-reader-text"> ' . esc_html__('(opens in a new tab)', 'clearyvo-seo') . '</span></a>'; 16 17 if (current_user_can('edit_post', $id)) $h .= ' <a class="wpsc-edit" href="' . esc_url(get_edit_post_link($id, 'raw')) . '">' . esc_html__('Edit', 'clearyvo-seo') . '<span class="screen-reader-text"> ' . esc_html(self::title($id)) . '</span></a>'; 18 if (class_exists('CVSEO_Visual')) $h .= CVSEO_Visual::button((int) $id); // Clearyvo SEO snippet editor right here 17 19 return $h; 18 20 } … … 67 69 if (!$can) $note .= ' ' . __('An editor or administrator can scan the site.', 'clearyvo-seo'); 68 70 WPSiteChecker_UI::action_bar([ 69 'buttons' => $can ? [['label' => __('Scan site', 'clearyvo-seo'), 'primary' => true, 'id' => 'wpsc-audit-run']] : [], 71 'buttons' => array_merge($can ? [['label' => __('Scan site', 'clearyvo-seo'), 'primary' => true, 'id' => 'wpsc-audit-run']] : [], 72 class_exists('CVSEO_Visual') && CVSEO_Visual::available() ? [['label' => __('Visual editor', 'clearyvo-seo'), 'primary' => true, 'class' => 'cvseo-vis-btn', 'attrs' => ['data-cvseo-visual' => '0'], 'title' => __('Choose a page and open it in the page editor with the Clearyvo SEO panel', 'clearyvo-seo')]] : []), 70 73 'status' => WPSiteChecker_UI::last_run_text($ts, 'scan'), 71 74 'status_id' => 'wpsc-audit-status', … … 114 117 $cards = [ 115 118 ['alt', __('Image ALT texts', 'clearyvo-seo'), [ 116 [$s['noalt'] + $s['emptyalt'], __('images without ALT text', 'clearyvo-seo'), 'warning' ],119 [$s['noalt'] + $s['emptyalt'], __('images without ALT text', 'clearyvo-seo'), 'warning', ['scan' => 'noalt']], 117 120 [$nokwAlt, __('pages where no image ALT contains the focus keyword', 'clearyvo-seo'), 'notice']]], 118 121 ['headings', __('Headings', 'clearyvo-seo'), [ … … 148 151 $n = (int) $line[0]; 149 152 $sev = isset($line[2]) && in_array($line[2], ['critical', 'warning', 'notice'], true) ? $line[2] : 'warning'; 150 $out[] = ['severity' => $n ? $sev : 'ok', 'count' => $n, 'text' => WPSiteChecker_Family::num($n) . ' ' . (string) $line[1], 'area' => (string) $c[1], 'tab' => sanitize_key((string) $c[0])]; 153 $out[] = ['severity' => $n ? $sev : 'ok', 'count' => $n, 'text' => WPSiteChecker_Family::num($n) . ' ' . (string) $line[1], 'area' => (string) $c[1], 'tab' => sanitize_key((string) $c[0]), 154 'args' => isset($line[3]) && is_array($line[3]) ? array_map('sanitize_key', $line[3]) : []]; 151 155 } 152 156 } … … 233 237 WPSiteChecker_UI::cards([ 234 238 ['label' => __('Score', 'clearyvo-seo'), 'value' => $s ? $s['score'] : null, 'tone' => 'score'], 235 ['label' => __('Images without ALT text', 'clearyvo-seo'), 'value' => $s ? $s['noalt'] + $s['emptyalt'] : null, 'tone' => 'warning', 'url' => $s && $s['noalt'] + $s['emptyalt'] ? WPSC_Admin::url('alt' ) : ''],239 ['label' => __('Images without ALT text', 'clearyvo-seo'), 'value' => $s ? $s['noalt'] + $s['emptyalt'] : null, 'tone' => 'warning', 'url' => $s && $s['noalt'] + $s['emptyalt'] ? WPSC_Admin::url('alt', ['scan' => 'noalt']) : ''], 236 240 ['label' => __('Pages with heading problems', 'clearyvo-seo'), 'value' => $s ? $s['hp'] : null, 'tone' => 'warning', 'url' => $s && $s['hp'] ? WPSC_Admin::url('headings') : ''], 237 241 ['label' => __('Shared focus keywords', 'clearyvo-seo'), 'value' => $s ? $s['conflicts'] : null, 'tone' => 'warning', 'url' => $s && $s['conflicts'] ? WPSC_Admin::url('keywords') : ''], … … 263 267 $h .= '<tr data-severity="' . esc_attr($r['severity']) . '" data-area="' . esc_attr($r['tab']) . '"><td class="column-severity">' . WPSiteChecker_UI::severity_badge_html($r['severity']) . '</td>' 264 268 . '<td class="column-item">' . esc_html($r['text']) . '</td><td class="column-area">' . esc_html($r['area']) . '</td>' 265 . '<td class="column-actions"><a href="' . esc_url(WPSC_Admin::url($r['tab'] )) . '">' . esc_html__('Show and fix', 'clearyvo-seo') . '<span class="screen-reader-text">: ' . esc_html($r['text']) . '</span></a></td></tr>';269 . '<td class="column-actions"><a href="' . esc_url(WPSC_Admin::url($r['tab'], $r['args'] ?? [])) . '">' . esc_html__('Show and fix', 'clearyvo-seo') . '<span class="screen-reader-text">: ' . esc_html($r['text']) . '</span></a></td></tr>'; 266 270 } 267 271 WPSiteChecker_UI::html($h . '</tbody>'); … … 304 308 if (!array_filter(array_column($rows, 'issues'))) { if (!$h['site']) WPSiteChecker_UI::empty_clean(WPSC_Admin::stats()['ts'], 'scan'); return; } 305 309 uasort($rows, function ($a, $b) { return count($b['issues']) <=> count($a['issues']); }); 310 do_action('wpsc_meta_before_table', $rows, $plugin); // Clearyvo SEO Pro: AI description drafts for many pages 306 311 $popts = ['' => __('All problems', 'clearyvo-seo')]; 307 312 foreach ($labels as $k => $l) $popts[$k] = $l[0]; … … 414 419 $rows = WPSC_Audit::meta(); 415 420 uasort($rows, function ($a, $b) { return count($b['issues']) <=> count($a['issues']); }); 421 do_action('wpsc_meta_before_table', $rows, $plugin); // Clearyvo SEO Pro: AI description drafts for many pages 416 422 $popts = ['' => __('All problems', 'clearyvo-seo')]; 417 423 foreach ($labels as $k => $l) $popts[$k] = $l[0]; -
clearyvo-seo/trunk/readme.txt
r3788085 r3790680 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 0. 5.37 Stable tag: 0.6.5 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 125 125 126 126 == Changelog == 127 128 = 0.6.5 = 129 * Fix: back button of the page editor, opened from a Clearyvo SEO screen: replaced through the editor's own dashboard-button slot, so it returns to Clearyvo SEO whatever the button looks like; the earlier click handling stays as a fallback. 130 131 = 0.6.4 = 132 * Fix: the page editor's back button now returns to the Clearyvo SEO screen the page was opened from on every WordPress version (the button is found by where it links to, not by its class name, which changed in newer versions). 133 134 = 0.6.3 = 135 * Fix: the Image ALT tab now shows how many pages the last scan found with images without ALT (or with an empty ALT), like the other tabs. 136 * New: "Last scan" filter on the Image ALT tab: "Missing ALT in the last scan" lists only those pages. The Overview card and "Show and fix" open the tab with this filter. 137 * New: each of those pages is marked with what the scan found ("1 image without ALT"). The scan reads the page as it is shown, so an ALT that exists in the Media Library but not in the page (or is dropped by the theme) is still reported; the mark makes that visible. 138 * Opened from a Clearyvo SEO screen with "Visual editor", the page editor's back button returns to that screen instead of the list of pages. 139 140 = 0.6.2 = 141 * Changed: the "Visual editor" button next to "Scan site" asks for the page, then opens it in the page editor with the Clearyvo SEO panel already open (checks, Google preview, snippet editor). The "Visual editor" buttons in the scan lists go there directly. 142 * Look: the "Edit SEO" button of the page editor is a filled button in the admin colour, open or closed. Scan site, Visual editor and Export CSV are the same blue button on the Clearyvo SEO screens. 143 144 = 0.6.1 = 145 * New: Visual editor on the Clearyvo SEO screens. The "Visual editor" button next to "Scan site" lets you pick any page and edit its Google and social snippet right there; every page in the scan lists has its own "Visual editor" button too. Saved with one click, the list row shows the new snippet at once. 146 * Changed: in the page editor the "Edit SEO" button now opens the whole Clearyvo SEO panel (checks, snippet, settings); the small magnifier icon on the toolbar is gone. The snippet editor opens from the panel (the Google preview or "Open the snippet editor"). 147 * Fix: the snippet editor window had its tabs and score drawn over its title on some WordPress versions. It now has its own header (page name, score, close), the tabs and the desktop / mobile switch on one line, and a fixed footer. 148 * For developers: "clearyvo-seo.snippet.field" JavaScript filter (a slot under the title, description and social fields of the snippet editor) and the "wpsc_meta_before_table" action. 149 150 = 0.6.0 = 151 * New: visual snippet editor in the block editor. A big "Edit SEO" button with the live score sits next to Save / Publish and opens it; clicking the Google preview in the side panel opens it too. 152 * Google preview for desktop and mobile with favicon, site name, breadcrumb address and date; title and description are measured in pixels against the width Google shows on each device; the focus phrase is bold, as in the search results. 153 * Variables (post title, separator, site title, category, excerpt, date, year…) insert at the cursor with one click. 154 * Address (slug) can be edited in the same window. 155 * Social sharing tab: Facebook / LinkedIn and X card previews, social image chosen from the Media Library. 127 156 128 157 = 0.5.3 = … … 248 277 == Upgrade Notice == 249 278 279 = 0.6.5 = 280 Back button of the page editor returns to Clearyvo SEO when opened from there. 281 282 = 0.6.4 = 283 Back button of the page editor returns to Clearyvo SEO when opened from there. 284 285 = 0.6.3 = 286 Image ALT tab: count, "Missing ALT in the last scan" filter and per-page marks. Settings and data are unchanged. 287 288 = 0.6.2 = 289 Visual editor opens the page in the page editor with the SEO panel; one button style. Settings and data are unchanged. 290 291 = 0.6.1 = 292 Visual editor on the Clearyvo SEO screens, one "Edit SEO" button in the page editor, tidier snippet editor window. Settings and data are unchanged. 293 294 = 0.6.0 = 295 New visual snippet editor: "Edit SEO" button next to Publish, desktop / mobile Google preview, variable chips, social card previews. Settings and data are unchanged. 296 250 297 = 0.5.0 = 251 298 Unified Clearyvo interface: sub-navigation, summary cards, filterable findings with Export CSV, one Settings form. Settings and data are unchanged. -
clearyvo-seo/trunk/seo/admin/class-cvseo-sidebar.php
r3787975 r3790680 1 1 <?php 2 2 /** 3 * Block editor sidebar: live checks by topic, fields, Google preview and a status light. 3 * Block editor sidebar: live checks by topic, fields, Google preview, a status light and the snippet editor 4 * (modal with desktop / mobile Google preview and social cards), opened from a big button in the editor header. 4 5 * 5 6 * @package ClearyvoSEO … … 29 30 30 31 /** 32 * The shared snippet core (helpers, analysis, snippet editor window) and its styles, for the block editor and the 33 * admin "Visual editor". 34 */ 35 public static function register_core() { 36 $deps = array( 'wp-element', 'wp-components', 'wp-hooks' ); 37 foreach ( array( 'wp-block-editor', 'wp-media-utils' ) as $opt ) { 38 if ( wp_script_is( $opt, 'registered' ) ) { 39 $deps[] = $opt; 40 } 41 } 42 wp_register_script( 'cvseo-snippet-core', CVSEO_URL . 'assets/js/snippet-core.js', $deps, CVSEO_VERSION, true ); 43 wp_register_style( 'cvseo-snippet', CVSEO_URL . 'assets/css/snippet.css', array( 'wp-components' ), CVSEO_VERSION ); 44 } 45 46 /** 47 * Settings every snippet editor needs, whatever page it edits. 48 * 49 * @return array 50 */ 51 public static function base_config() { 52 return array( 53 'sitename' => get_bloginfo( 'name' ), 54 'tagline' => get_bloginfo( 'description' ), 55 'sep' => CVSEO_Settings::get( 'separator' ), 56 'autoDesc' => (bool) CVSEO_Settings::get( 'auto_description' ), 57 'home' => home_url( '/' ), 58 'homeHost' => wp_parse_url( home_url(), PHP_URL_HOST ), 59 'favicon' => get_site_icon_url( 32 ), 60 'blogPublic' => (bool) get_option( 'blog_public' ), 61 'maxDensity' => CVSEO_Analysis::MAX_DENSITY, 62 'pro' => defined( 'WPSCP_VERSION' ), 63 'proUrl' => class_exists( 'WPSiteChecker_Family' ) ? WPSiteChecker_Family::shop_url() : 'https://wp-sitechecker.com/pro/', 64 'auditUrl' => admin_url( 'admin.php?page=wp-seochecker&tab=alt&s=' ), 65 'schema' => CVSEO_Meta::schema_types(), 66 'logo' => class_exists( 'WPSiteChecker_Family' ) ? WPSiteChecker_Family::logo_url( 'seochecker' ) : plugins_url( 'family/logos/seochecker.svg', CVSEO_FILE ), 67 'conflict' => ! CVSEO_Settings::output_enabled() ? implode( ', ', CVSEO_Settings::conflicts() ) : '', 68 'backUrl' => admin_url( 'admin.php?page=wp-seochecker' ), 69 ); 70 } 71 72 /** 31 73 * Script and data. 32 74 */ … … 43 85 $template = $ctx && $ctx->is_front ? CVSEO_Settings::get( 'title_home' ) : CVSEO_Settings::title_template_pt( $post->post_type ); 44 86 $deps = array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-api-fetch', 'wp-url', 'wp-hooks', 'wp-edit-post' ); 45 if ( wp_script_is( 'wp-editor', 'registered' ) ) { 46 $deps[] = 'wp-editor'; 87 foreach ( array( 'wp-editor', 'wp-block-editor', 'wp-date' ) as $opt ) { 88 if ( wp_script_is( $opt, 'registered' ) ) { 89 $deps[] = $opt; 90 } 47 91 } 92 self::register_core(); 93 $deps[] = 'cvseo-snippet-core'; 48 94 wp_enqueue_script( 'cvseo-sidebar', CVSEO_URL . 'assets/js/sidebar.js', $deps, CVSEO_VERSION, true ); 49 wp_enqueue_style( 'cvseo-sidebar', CVSEO_URL . 'assets/css/sidebar.css', array( ), CVSEO_VERSION );95 wp_enqueue_style( 'cvseo-sidebar', CVSEO_URL . 'assets/css/sidebar.css', array( 'cvseo-snippet' ), CVSEO_VERSION ); 50 96 51 97 $cats = array(); … … 58 104 'cvseo-sidebar', 59 105 'cvseoSidebar', 60 array( 61 'template' => $template, 62 'sitename' => get_bloginfo( 'name' ), 63 'tagline' => get_bloginfo( 'description' ), 64 'sep' => CVSEO_Settings::get( 'separator' ), 65 'autoDesc' => (bool) CVSEO_Settings::get( 'auto_description' ), 66 'home' => home_url( '/' ), 67 'homeHost' => wp_parse_url( home_url(), PHP_URL_HOST ), 68 'blogPublic' => (bool) get_option( 'blog_public' ), 69 'ptNoindex' => CVSEO_Settings::pt_noindex( $post->post_type ), 70 'isPage' => 'page' === $post->post_type, 71 'maxDensity' => CVSEO_Analysis::MAX_DENSITY, 72 'pro' => defined( 'WPSCP_VERSION' ), 73 'proUrl' => class_exists( 'WPSiteChecker_Family' ) ? WPSiteChecker_Family::shop_url() : 'https://wp-sitechecker.com/pro/', 74 'auditUrl' => admin_url( 'admin.php?page=wp-seochecker&tab=alt&s=' ), 75 'schema' => CVSEO_Meta::schema_types(), 76 'cats' => (object) $cats, 77 'logo' => class_exists( 'WPSiteChecker_Family' ) ? WPSiteChecker_Family::logo_url( 'seochecker' ) : plugins_url( 'family/logos/seochecker.svg', CVSEO_FILE ), 78 'conflict' => ! CVSEO_Settings::output_enabled() ? implode( ', ', CVSEO_Settings::conflicts() ) : '', 106 array_merge( 107 self::base_config(), 108 array( 109 'template' => $template, 110 'ptNoindex' => CVSEO_Settings::pt_noindex( $post->post_type ), 111 'isPage' => 'page' === $post->post_type, 112 'cats' => (object) $cats, 113 ) 79 114 ) 80 115 ); -
clearyvo-seo/trunk/seo/assets/css/sidebar.css
r3787975 r3790680 66 66 .cvseo-sb .components-base-control { margin-bottom: 16px; } 67 67 .cvseo-sb .components-base-control:last-child { margin-bottom: 0; } 68 69 /* ---------------------------------------------------------------- 0.6.0: header button + snippet editor */ 70 71 /* Big "Edit SEO" button in the editor header: filled, in the admin colour like the editor's own buttons. */ 72 .cvseo-hb-slot { display: flex; align-items: center; margin-right: 4px; } 73 .cvseo-hb { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 5px 0 8px; border: 0; border-radius: 2px; cursor: pointer; 74 background: var(--wp-admin-theme-color, #3858e9); color: #fff; font: 500 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 75 white-space: nowrap; transition: background .15s; } 76 .cvseo-hb:hover { background: var(--wp-admin-theme-color-darker-10, #2145e6); color: #fff; } 77 .cvseo-hb:focus-visible { outline: 2px solid var(--wp-admin-theme-color, #3858e9); outline-offset: 2px; } 78 .cvseo-hb.is-open { background: var(--wp-admin-theme-color-darker-20, #183ad6); box-shadow: inset 0 -3px 0 rgba(255, 255, 255, .7); } 79 .cvseo-hb .cvseo-sb-logo { background: #fff; border-radius: 4px; } 80 .cvseo-hb-score { min-width: 26px; padding: 5px 7px; border-radius: 12px; font-size: 12px; font-weight: 600; text-align: center; color: #fff; } 81 .cvseo-hb-score.is-good { background: #00a32a; } .cvseo-hb-score.is-warn { background: #dba617; color: #1e1e1e; } .cvseo-hb-score.is-bad { background: #d63638; } 82 @media (max-width: 900px) { .cvseo-hb-label { display: none; } } 83 84 /* Clickable preview + button in the side panel. */ 85 .cvseo-sb-previewbtn { display: block; width: 100%; padding: 0; margin: 0; border: 0; background: none; text-align: left; cursor: pointer; } 86 .cvseo-sb-previewbtn .cvseo-sb-preview { margin-bottom: 8px; transition: border-color .15s, box-shadow .15s; } 87 .cvseo-sb-previewbtn:hover .cvseo-sb-preview, .cvseo-sb-previewbtn:focus-visible .cvseo-sb-preview { border-color: var(--wp-admin-theme-color, #3858e9); box-shadow: 0 0 0 1px var(--wp-admin-theme-color, #3858e9); } 88 .cvseo-sb .cvseo-sb-editbtn { width: 100%; justify-content: center; margin: 0 0 16px; } -
clearyvo-seo/trunk/seo/assets/js/sidebar.js
r3787975 r3790680 1 1 /** 2 * Clearyvo SEO – block editor sidebar.2 * Clearyvo SEO – block editor panel. 3 3 * Live checks on the unsaved content, grouped by topic: what is good gets a tick, what is not good enough a warning. 4 * No 0–100 score: keyword stuffing turns the light red instead of green. 4 * The big "Edit SEO" button in the header opens this panel; the snippet editor opens from inside it. 5 * Helpers and the snippet editor live in snippet-core.js (window.cvseoSnippet). 5 6 */ 6 ( function ( wp, cfg ) {7 ( function ( wp, cfg, S ) { 7 8 'use strict'; 8 if ( ! wp || ! wp.plugins || ! cfg ) {9 if ( ! wp || ! wp.plugins || ! cfg || ! S ) { 9 10 return; 10 11 } 12 S.configure( cfg ); 11 13 var el = wp.element.createElement; 12 14 var Fragment = wp.element.Fragment; … … 22 24 23 25 var SIDEBAR = 'clearyvo-seo-sidebar'; 24 var TITLE_PX = 580; 25 var DESC_MAX = 160; 26 27 // ---------------------------------------------------------------- helpers 28 29 var canvas = document.createElement( 'canvas' ).getContext( '2d' ); 30 function px( text, font ) { 31 canvas.font = font; 32 return Math.round( canvas.measureText( text ).width ); 33 } 34 function esc( s ) { 35 return String( s ).replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); 36 } 37 // Word forms: every word of the phrase may take a suffix ("alkatrész" also matches "alkatrészek", "alkatrészt"), 38 // which matters for Hungarian and most other European languages. 39 function phraseRe( phrase ) { 40 return phrase.trim().split( /\s+/ ).map( function ( w ) { 41 return esc( w ) + '\\p{L}{0,6}'; 42 } ).join( '[\\s\\u00a0-]+' ); 43 } 44 function countPhrase( phrase, text ) { 45 phrase = ( phrase || '' ).trim(); 46 if ( ! phrase || ! text ) { 47 return 0; 48 } 49 try { 50 return ( text.match( new RegExp( '(?<![\\p{L}\\p{N}])' + phraseRe( phrase ) + '(?![\\p{L}\\p{N}])', 'giu' ) ) || [] ).length; 51 } catch ( e ) { 52 return text.toLowerCase().split( phrase.toLowerCase() ).length - 1; 53 } 54 } 55 function words( text ) { 56 try { 57 return ( text.match( /[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu ) || [] ).length; 58 } catch ( e ) { 59 return text.split( /\s+/ ).filter( Boolean ).length; 60 } 61 } 62 function slugify( s ) { 63 return String( s ).toLowerCase().normalize( 'NFD' ).replace( /[̀-ͯ]/g, '' ).replace( /[^a-z0-9]+/g, '-' ).replace( /^-|-$/g, '' ); 64 } 65 function render( tpl, title ) { 66 var vars = { 67 '%title%': title || '', 68 '%sitename%': cfg.sitename || '', 69 '%tagline%': cfg.tagline || '', 70 '%sep%': cfg.sep || '–', 71 '%year%': String( new Date().getFullYear() ) 72 }; 73 var out = String( tpl || '' ).replace( /%[a-z_]+%/gi, function ( m ) { 74 return Object.prototype.hasOwnProperty.call( vars, m ) ? vars[ m ] : ''; 75 } ).replace( /\s+/g, ' ' ); 76 var sep = esc( cfg.sep || '' ); 77 if ( sep ) { 78 out = out.replace( new RegExp( '(\\s*' + sep + '\\s*){2,}', 'g' ), ' ' + cfg.sep + ' ' ); 79 out = out.replace( new RegExp( '^\\s*' + sep + '\\s*|\\s*' + sep + '\\s*$', 'g' ), '' ); 80 } 81 return out.trim(); 82 } 83 function excerptOf( text ) { 84 text = text.replace( /\s+/g, ' ' ).trim(); 85 if ( text.length <= 155 ) { 86 return text; 87 } 88 var cut = text.slice( 0, 156 ); 89 var sp = cut.lastIndexOf( ' ' ); 90 return cut.slice( 0, sp > 0 ? sp : 155 ).replace( /[\s,;:–-]+$/, '' ) + '…'; 91 } 92 93 // ---------------------------------------------------------------- analysis 94 95 function parse( html ) { 96 var doc = new DOMParser().parseFromString( '<body>' + ( html || '' ) + '</body>', 'text/html' ); 97 var body = doc.body; 98 Array.prototype.forEach.call( body.querySelectorAll( 'script, style' ), function ( n ) { 99 n.remove(); 100 } ); 101 var blocks = []; 102 Array.prototype.forEach.call( body.querySelectorAll( 'p, li, blockquote, h1, h2, h3, h4, h5, h6, td, figcaption' ), function ( n ) { 103 blocks.push( n ); 104 } ); 105 var text = ( body.textContent || '' ).replace( /\s+/g, ' ' ).trim(); 106 return { 107 body: body, 108 text: text, 109 paragraphs: Array.prototype.map.call( body.querySelectorAll( 'p' ), function ( p ) { 110 return ( p.textContent || '' ).trim(); 111 } ).filter( Boolean ), 112 headings: Array.prototype.map.call( body.querySelectorAll( 'h1, h2, h3, h4, h5, h6' ), function ( h ) { 113 return { level: parseInt( h.tagName.slice( 1 ), 10 ), text: ( h.textContent || '' ).trim() }; 114 } ), 115 images: Array.prototype.map.call( body.querySelectorAll( 'img' ), function ( i ) { 116 return { alt: i.hasAttribute( 'alt' ) ? i.getAttribute( 'alt' ).trim() : null, src: i.getAttribute( 'src' ) || '' }; 117 } ), 118 links: Array.prototype.map.call( body.querySelectorAll( 'a[href]' ), function ( a ) { 119 return { href: a.getAttribute( 'href' ), text: ( a.textContent || '' ).trim(), hasImg: !! a.querySelector( 'img' ) }; 120 } ), 121 blocks: blocks.length 122 }; 123 } 124 125 function analyse( d ) { 126 var sections = []; 127 var c = d.content; 128 var kw = ( d.kw || '' ).trim(); 129 var wc = words( c.text ); 130 131 // Title & description. 132 var s1 = []; 133 var tpx = px( d.title, '20px Arial' ); 134 if ( ! d.title ) { 135 s1.push( [ 'bad', 'The page has no title.' ] ); 136 } else if ( tpx > TITLE_PX ) { 137 s1.push( [ 'warn', 'The title is ' + tpx + ' px wide; Google cuts it after about ' + TITLE_PX + ' px. Shorten it or move the important words to the front.' ] ); 138 } else if ( d.title.length < 25 ) { 139 s1.push( [ 'warn', 'The title is only ' + d.title.length + ' characters. Describe the page a little more.' ] ); 140 } else { 141 s1.push( [ 'good', 'Title length is good (' + d.title.length + ' characters, ' + tpx + ' px).' ] ); 142 } 143 if ( ! d.descRaw ) { 144 s1.push( [ d.desc ? 'info' : 'warn', d.desc ? 'No description written: Google gets the start of the text. A written one usually gets more clicks.' : 'No description, and there is no text to take one from.' ] ); 145 } else if ( d.desc.length > DESC_MAX ) { 146 s1.push( [ 'warn', 'The description is ' + d.desc.length + ' characters; aim for 120–160, Google cuts the rest.' ] ); 147 } else if ( d.desc.length < 70 ) { 148 s1.push( [ 'warn', 'The description is only ' + d.desc.length + ' characters; 120–160 uses the space Google gives.' ] ); 149 } else { 150 s1.push( [ 'good', 'Description length is good (' + d.desc.length + ' characters).' ] ); 151 } 152 sections.push( { id: 'meta', title: 'Title & description', checks: s1 } ); 153 154 // Focus phrase. 155 var s2 = []; 156 if ( ! kw ) { 157 s2.push( [ 'info', 'No focus phrase. It is optional: add one if this page should be found for a specific search.' ] ); 158 } else { 159 var inT = countPhrase( kw, d.title ); 160 s2.push( inT === 0 ? [ 'warn', 'The focus phrase is not in the SEO title.' ] : ( inT > 1 ? [ 'bad', 'The focus phrase is in the title ' + inT + ' times. Once is enough; repeating it looks spammy.' ] : [ 'good', 'The focus phrase is in the title, once.' ] ) ); 161 var inD = countPhrase( kw, d.desc ); 162 s2.push( inD === 0 ? [ 'info', 'The focus phrase is not in the description (fine if it reads better without).' ] : ( inD > 1 ? [ 'warn', 'The focus phrase is repeated in the description.' ] : [ 'good', 'The focus phrase is in the description.' ] ) ); 163 var ks = slugify( kw ); 164 s2.push( ks && ( d.slug || '' ).indexOf( ks ) !== -1 ? [ 'good', 'The focus phrase is in the address (URL).' ] : [ 'info', 'The focus phrase is not in the address. Change the address only on new pages, or add a redirect.' ] ); 165 var n = countPhrase( kw, c.text ); 166 if ( wc === 0 ) { 167 s2.push( [ 'warn', 'There is no text yet to check the focus phrase in.' ] ); 168 } else if ( n === 0 ) { 169 s2.push( [ 'warn', 'The focus phrase does not appear in the text.' ] ); 170 } else { 171 var start = c.text.split( /\s+/ ).slice( 0, Math.max( 60, Math.round( wc * 0.1 ) ) ).join( ' ' ); 172 s2.push( countPhrase( kw, start ) ? [ 'good', 'The focus phrase appears near the start of the text.' ] : [ 'warn', 'The focus phrase does not appear in the first part of the text.' ] ); 173 var density = n * words( kw ) / wc * 100; 174 if ( wc < 100 && ! ( n >= 3 && density > cfg.maxDensity ) ) { 175 s2.push( [ 'info', 'Used ' + n + ( n === 1 ? ' time' : ' times' ) + '. The text is still too short to judge whether the phrase is used naturally.' ] ); 176 } else if ( n >= 3 && density > cfg.maxDensity ) { 177 s2.push( [ 'bad', 'Over-optimised: the focus phrase is ' + density.toFixed( 1 ) + '% of the text (' + n + ' times). Write naturally and use other words for the same thing.' ] ); 178 } else { 179 s2.push( [ 'good', 'Natural use: ' + n + ( n === 1 ? ' time' : ' times' ) + ' in ' + wc + ' words (' + density.toFixed( 1 ) + '%).' ] ); 180 } 181 } 182 var subs = c.headings.filter( function ( h ) { 183 return h.level >= 2 && h.level <= 4; 184 } ); 185 if ( subs.length ) { 186 s2.push( subs.some( function ( h ) { 187 return countPhrase( kw, h.text ); 188 } ) ? [ 'good', 'The focus phrase is in a subheading.' ] : [ 'info', 'The focus phrase is in no subheading (H2–H4). One is enough, where it fits.' ] ); 189 } 190 if ( c.images.length ) { 191 var withKw = c.images.filter( function ( i ) { 192 return i.alt && countPhrase( kw, i.alt ); 193 } ).length; 194 if ( withKw === 0 ) { 195 s2.push( [ 'info', 'No image ALT text contains the focus phrase. Use it on one image where it describes the picture.' ] ); 196 } else if ( withKw >= 3 && withKw > c.images.length / 2 ) { 197 s2.push( [ 'warn', 'The focus phrase is in ' + withKw + ' image ALT texts. On every image it counts as keyword stuffing; describe the pictures instead.' ] ); 198 } else { 199 s2.push( [ 'good', 'The focus phrase is in an image ALT text.' ] ); 200 } 201 } 202 if ( d.others === null ) { 203 s2.push( [ 'info', 'Checking whether other pages use the same focus phrase…' ] ); 204 } else if ( d.others.length ) { 205 s2.push( [ 'warn', 'Other pages use the same focus phrase (' + d.others.map( function ( o ) { 206 return '“' + o.title + '”'; 207 } ).join( ', ' ) + '). They compete with each other in Google: give each page its own phrase.' ] ); 208 } else { 209 s2.push( [ 'good', 'No other page targets this focus phrase.' ] ); 210 } 211 } 212 sections.push( { id: 'kw', title: 'Focus phrase', checks: s2 } ); 213 214 // Content. 215 var s3 = []; 216 var minWords = cfg.isPage ? 150 : 300; 217 s3.push( wc >= minWords ? [ 'good', wc + ' words of text.' ] : [ cfg.isPage ? 'info' : 'warn', 'Only ' + wc + ' words. Pages that answer a question fully tend to rank better; aim for ' + minWords + '+ if the topic needs it.' ] ); 218 var h1 = c.headings.filter( function ( h ) { 219 return h.level === 1; 220 } ).length; 221 if ( h1 > 1 ) { 222 s3.push( [ 'warn', 'The text contains ' + h1 + ' H1 headings. A page should have one H1; make the others H2.' ] ); 223 } else if ( h1 === 1 ) { 224 s3.push( [ 'info', 'The text contains an H1. That is right if your theme does not show the page title as a heading (full-width templates, page builders); if it does, make this one H2. The site audit checks the real page.' ] ); 225 } else { 226 s3.push( [ 'good', 'No extra H1 in the text: the page title is the H1.' ] ); 227 } 228 var skipped = false; 229 var prev = 1; 230 c.headings.forEach( function ( h ) { 231 if ( h.level > prev + 1 ) { 232 skipped = true; 233 } 234 prev = h.level; 235 } ); 236 if ( c.headings.length ) { 237 s3.push( skipped ? [ 'warn', 'A heading level is skipped (for example H2 → H4). Screen readers and Google read the levels as the outline of the page.' ] : [ 'good', 'Heading levels follow each other in order.' ] ); 238 } else if ( wc > 300 ) { 239 s3.push( [ 'warn', 'No subheadings in ' + wc + ' words. Split the text with H2 headings.' ] ); 240 } 241 var longP = c.paragraphs.filter( function ( p ) { 242 return words( p ) > 150; 243 } ).length; 244 if ( c.paragraphs.length ) { 245 s3.push( longP ? [ 'warn', longP + ( longP === 1 ? ' paragraph is' : ' paragraphs are' ) + ' longer than 150 words. Shorter paragraphs are easier to read on a phone.' ] : [ 'good', 'Paragraphs are a readable length.' ] ); 246 } 247 var sentences = c.text.split( /(?<=[.!?…])\s+/ ).filter( function ( x ) { 248 return words( x ) > 0; 249 } ); 250 if ( sentences.length >= 5 ) { 251 var longS = sentences.filter( function ( x ) { 252 return words( x ) > 25; 253 } ).length; 254 var share = Math.round( longS / sentences.length * 100 ); 255 s3.push( share > 25 ? [ 'warn', share + '% of the sentences are longer than 25 words. Break some of them up.' ] : [ 'good', 'Sentence length is easy to read.' ] ); 256 } 257 sections.push( { id: 'content', title: 'Content', checks: s3 } ); 258 259 // Images. 260 var s4 = []; 261 if ( ! c.images.length ) { 262 s4.push( [ 'info', 'No images in the text. A photo of your work or a diagram helps readers and can bring visitors from Google Images.' ] ); 263 } else { 264 var missing = c.images.filter( function ( i ) { 265 return ! i.alt; 266 } ).length; 267 if ( missing ) { 268 s4.push( [ 'warn', missing + ' of ' + c.images.length + ( c.images.length === 1 ? ' image has' : ' images have' ) + ' no ALT text. The Image ALT tab writes a draft for each from this page.', 'alt' ] ); 269 if ( ! cfg.pro ) { 270 s4.push( [ 'pro', 'Clearyvo SEO Pro writes all missing ALT texts with AI (your own Claude or OpenAI key), and every new upload gets one.' ] ); 271 } 272 } else { 273 s4.push( [ 'good', 'All ' + c.images.length + ( c.images.length === 1 ? ' image has' : ' images have' ) + ' ALT text.' ] ); 274 } 275 var alts = c.images.map( function ( i ) { 276 return ( i.alt || '' ).toLowerCase(); 277 } ).filter( Boolean ); 278 var dupes = alts.length - alts.filter( function ( a, i ) { 279 return alts.indexOf( a ) === i; 280 } ).length; 281 if ( alts.length > 1 ) { 282 s4.push( dupes ? [ 'warn', dupes + ' image ALT ' + ( dupes === 1 ? 'text repeats' : 'texts repeat' ) + ' another one. Each ALT should describe its own picture.' ] : [ 'good', 'Every image has its own ALT text.' ] ); 283 } 284 } 285 sections.push( { id: 'images', title: 'Images', checks: s4, extra: 'images' } ); 286 287 // Links. 288 var s5 = []; 289 var internal = 0; 290 var external = 0; 291 var empty = 0; 292 c.links.forEach( function ( l ) { 293 if ( ! l.text && ! l.hasImg ) { 294 empty++; 295 } 296 if ( /^(mailto|tel|#)/.test( l.href ) ) { 297 return; 298 } 299 try { 300 var u = new URL( l.href, cfg.home ); 301 if ( u.host === cfg.homeHost ) { 302 internal++; 303 } else { 304 external++; 305 } 306 } catch ( e ) {} 307 } ); 308 s5.push( internal ? [ 'good', internal + ' link' + ( internal === 1 ? '' : 's' ) + ' to other pages of the site.' ] : [ wc >= 300 ? 'warn' : 'info', 'No links to other pages of the site. Link to related pages: it helps visitors and Google find them.' ] ); 309 s5.push( external ? [ 'good', external + ' link' + ( external === 1 ? '' : 's' ) + ' to other websites.' ] : [ 'info', 'No links to other websites. Link to a source or standard where it helps the reader.' ] ); 310 if ( empty ) { 311 s5.push( [ 'warn', empty + ' link' + ( empty === 1 ? ' has' : 's have' ) + ' no text. Screen readers and Google cannot tell where ' + ( empty === 1 ? 'it leads' : 'they lead' ) + '.' ] ); 312 } 313 sections.push( { id: 'links', title: 'Links', checks: s5 } ); 314 315 // Indexing. 316 var s6 = []; 317 if ( ! cfg.blogPublic ) { 318 s6.push( [ 'bad', 'The whole site is hidden from search engines (Settings → Reading).' ] ); 319 } 320 var noindex = d.noindex === '1' || ( d.noindex === '' && cfg.ptNoindex ); 321 s6.push( noindex ? [ 'warn', 'This page is set to noindex: it will not appear in Google.' ] : [ 'good', 'Search engines may show this page.' ] ); 322 if ( d.canonical && d.link && d.canonical.replace( /\/$/, '' ) !== d.link.replace( /\/$/, '' ) ) { 323 s6.push( [ 'info', 'The canonical URL points to another address, so Google shows that one instead of this page.' ] ); 324 } 325 if ( d.nofollow ) { 326 s6.push( [ 'info', 'nofollow is on: Google does not follow the links of this page.' ] ); 327 } 328 if ( cfg.conflict ) { 329 s6.push( [ 'info', cfg.conflict + ' is still active, so it prints the SEO tags. Import its data in SEO → Import, then deactivate it.' ] ); 330 } 331 sections.push( { id: 'index', title: 'Indexing', checks: s6 } ); 332 333 // Score 0–100: every check that is not just a tip counts, weighted by its topic. A red problem (stuffing, 334 // phrase repeated in the title, hidden site) caps the score at 49, so over-optimising never shows green. 335 var weight = { meta: 3, kw: 2, content: 1.5, images: 2, links: 1, index: 2 }; 336 var got = 0; 337 var max = 0; 338 sections.forEach( function ( s ) { 339 s.checks.forEach( function ( k ) { 340 var w = weight[ s.id ] || 1; 341 if ( k[ 0 ] === 'good' || k[ 0 ] === 'warn' || k[ 0 ] === 'bad' ) { 342 max += w; 343 got += k[ 0 ] === 'good' ? w : ( k[ 0 ] === 'warn' ? w * 0.4 : 0 ); 344 } 345 } ); 346 } ); 347 var count = { bad: 0, warn: 0, good: 0 }; 348 sections.forEach( function ( s ) { 349 s.checks.forEach( function ( k ) { 350 if ( count[ k[ 0 ] ] !== undefined ) { 351 count[ k[ 0 ] ]++; 352 } 353 } ); 354 } ); 355 var score = max ? Math.round( got / max * 100 ) : 0; 356 if ( count.bad ) { 357 score = Math.min( score, 49 ); 358 } 359 return { sections: sections, count: count, score: score, status: score >= 80 ? 'good' : ( score >= 50 ? 'warn' : 'bad' ) }; 360 } 361 362 // ---------------------------------------------------------------- UI 363 364 var COLORS = { good: '#00a32a', warn: '#dba617', bad: '#d63638' }; 365 366 function Logo( props ) { 367 return el( 'img', { src: cfg.logo, width: props.size || 20, height: props.size || 20, alt: '', className: 'cvseo-sb-logo' } ); 368 } 369 370 /** Toolbar button: Clearyvo SEO logo and the score in a coloured pill. */ 371 function ToolbarIcon( props ) { 372 var ref = wp.element.useRef( null ); 373 // The editor sizes toolbar icon buttons as fixed squares (width, max-width or flex-basis, depending on the 374 // WordPress version). Inline styles with !important on the button itself win over every stylesheet. 375 useEffect( function () { 376 var btn = ref.current && ref.current.closest( 'button' ); 377 if ( ! btn ) { 378 return; 379 } 380 [ [ 'width', 'auto' ], [ 'min-width', '0' ], [ 'max-width', 'none' ], [ 'flex', '0 0 auto' ], [ 'padding', '0 6px' ], [ 'overflow', 'visible' ] ].forEach( function ( r ) { 381 btn.style.setProperty( r[ 0 ], r[ 1 ], 'important' ); 382 } ); 383 } ); 384 return el( 'span', { className: 'cvseo-tb', ref: ref }, 385 el( Logo, { size: 20 } ), 386 el( 'span', { className: 'cvseo-tb-score is-' + props.status }, 'SEO ' + props.score ) 387 ); 388 } 389 390 /** Score ring at the top of the sidebar. */ 391 function Ring( props ) { 392 var r = 26; 393 var len = 2 * Math.PI * r; 394 return el( 'svg', { width: 64, height: 64, viewBox: '0 0 64 64', className: 'cvseo-sb-ring', 'aria-hidden': true }, 395 el( 'circle', { cx: 32, cy: 32, r: r, fill: 'none', stroke: '#e0e0e0', strokeWidth: 6 } ), 396 el( 'circle', { cx: 32, cy: 32, r: r, fill: 'none', stroke: COLORS[ props.status ], strokeWidth: 6, strokeLinecap: 'round', 397 strokeDasharray: len, strokeDashoffset: len * ( 1 - props.score / 100 ), transform: 'rotate(-90 32 32)' } ), 398 el( 'text', { x: 32, y: 37, textAnchor: 'middle', fontSize: 17, fontWeight: 700, fill: '#1e1e1e' }, String( props.score ) ) 399 ); 400 } 401 402 function Check( props ) { 403 var k = props.check; 404 var mark = { good: '✓', warn: '!', bad: '✕', info: 'i', pro: 'Pro' }[ k[ 0 ] ]; 405 var extra = null; 406 if ( k[ 2 ] === 'alt' && props.title ) { 407 extra = el( 'a', { href: cfg.auditUrl + encodeURIComponent( props.title ), target: '_blank', rel: 'noopener noreferrer', className: 'cvseo-sb-link' }, 'Open the ALT drafts ↗' ); 408 } 409 if ( k[ 0 ] === 'pro' ) { 410 extra = el( 'a', { href: cfg.proUrl, target: '_blank', rel: 'noopener noreferrer', className: 'cvseo-sb-link' }, 'See Pro ↗' ); 411 } 412 return el( 'li', { className: 'cvseo-sb-check is-' + k[ 0 ] }, 413 el( 'span', { className: 'cvseo-sb-mark', 'aria-label': k[ 0 ] }, mark ), 414 el( 'span', { className: 'cvseo-sb-text' }, k[ 1 ], extra ? ' ' : null, extra ) 415 ); 416 } 417 418 function sectionBadge( checks ) { 419 var bad = checks.filter( function ( k ) { 420 return k[ 0 ] === 'bad'; 421 } ).length; 422 var warn = checks.filter( function ( k ) { 423 return k[ 0 ] === 'warn'; 424 } ).length; 425 var good = checks.filter( function ( k ) { 426 return k[ 0 ] === 'good'; 427 } ).length; 428 if ( bad ) { 429 return { cls: 'bad', text: bad + ' problem' + ( bad > 1 ? 's' : '' ) }; 430 } 431 if ( warn ) { 432 return { cls: 'warn', text: warn + ' to improve' }; 433 } 434 return { cls: good ? 'good' : 'info', text: good ? 'All good' : 'Tips' }; 435 } 436 437 function Preview( props ) { 438 var title = props.title; 439 if ( px( title, '20px Arial' ) > TITLE_PX ) { 440 while ( title.length && px( title + ' …', '20px Arial' ) > TITLE_PX ) { 441 title = title.slice( 0, -1 ); 442 } 443 title = title.trim() + ' …'; 444 } 445 var desc = props.desc.length > 158 ? props.desc.slice( 0, 155 ).trim() + ' …' : props.desc; 446 var url = String( props.link || cfg.home ).replace( /^https?:\/\//, '' ).replace( /\/$/, '' ).split( '/' ).join( ' › ' ); 447 return el( 'div', { className: 'cvseo-sb-preview', 'aria-label': 'Google preview' }, 448 el( 'div', { className: 'cvseo-sb-p-url' }, url ), 449 el( 'div', { className: 'cvseo-sb-p-title' }, title || '(no title)' ), 450 el( 'div', { className: 'cvseo-sb-p-desc' }, desc ) 451 ); 452 } 453 454 function Meter( props ) { 455 var r = props.value / props.max; 456 var cls = r > 1 ? 'bad' : ( r < props.low ? 'warn' : 'good' ); 457 return el( 'div', { className: 'cvseo-sb-meter' }, el( 'span', { className: 'is-' + cls, style: { width: Math.min( 100, r * 100 ) + '%' } } ) ); 458 } 459 460 function useDebounced( value, ms ) { 461 var st = useState( value ); 462 useEffect( function () { 463 var t = setTimeout( function () { 464 st[ 1 ]( value ); 465 }, ms ); 466 return function () { 467 clearTimeout( t ); 468 }; 469 }, [ value ] ); 470 return st[ 0 ]; 471 } 26 var TITLE_PX = S.TITLE_PX; 27 var DESC_MAX = S.DESC_MAX; 28 var px = S.px, render = S.render, excerptOf = S.excerptOf, parse = S.parse, analyse = S.analyse, useDebounced = S.useDebounced; 29 var Logo = S.Logo, ToolbarIcon = S.ToolbarIcon, Ring = S.Ring, Check = S.Check, sectionBadge = S.sectionBadge, Preview = S.Preview, Meter = S.Meter; 30 var SnippetEditor = S.SnippetEditor, useHeaderSlot = S.useHeaderSlot, HeaderButton = S.HeaderButton; 472 31 473 32 function Sidebar() { … … 482 41 link: ed.getPermalink ? ed.getPermalink() : '', 483 42 meta: ed.getEditedPostAttribute( 'meta' ) || {}, 484 cats: ed.getEditedPostAttribute( 'categories' ) || [] 43 cats: ed.getEditedPostAttribute( 'categories' ) || [], 44 date: ed.getEditedPostAttribute( 'date' ) || '', 45 author: ed.getEditedPostAttribute( 'author' ) || 0, 46 featured: ed.getEditedPostAttribute( 'featured_media' ) || 0 485 47 }; 48 }, [] ); 49 var extra = useSelect( function ( select ) { 50 var core = select( 'core' ); 51 var media = data.featured && core && core.getMedia ? core.getMedia( data.featured ) : null; 52 var user = data.author && core && core.getUser ? core.getUser( data.author ) : null; 53 return { featured: media && media.source_url ? media.source_url : '', author: user && user.name ? user.name : '' }; 54 }, [ data.featured, data.author ] ); 55 var modalSt = useState( false ); 56 var slot = useHeaderSlot(); 57 var panel = usePanel(); 58 // Opened from a Clearyvo SEO view (?cvseo_from=tab): the editor's back button returns to that view, not to the 59 // list of pages. The parameter stays in the address, so a reload keeps it. 60 useEffect( function () { 61 var m = window.location.search.match( /[?&]cvseo_from=([a-z_]+)/ ); 62 if ( ! m || ! cfg.backUrl ) { 63 return; 64 } 65 var back = cfg.backUrl + '&tab=' + m[ 1 ]; 66 // The back button differs between WordPress versions (W logo, site icon, "<" arrow, its class names), but it 67 // is always a link in the editor header to the list of posts or pages (edit.php). Catch that link, plus 68 // the known class names in case a version renders it as a button. 69 var HEADER = '.editor-header, .edit-post-header, .interface-interface-skeleton__header'; 70 var CLOSE = '.editor-fullscreen-mode-close, .edit-post-fullscreen-mode-close, [class*="fullscreen-mode-close"], [class*="header__back"]'; 71 function isBack( node ) { 72 if ( ! node || ! node.closest || ! node.closest( HEADER ) ) { 73 return false; 74 } 75 if ( node.matches( CLOSE ) ) { 76 return true; 77 } 78 // A back button without a link (rendered as a button): known labels, in English and Hungarian. 79 var label = ( node.getAttribute( 'aria-label' ) || node.getAttribute( 'title' ) || '' ).toLowerCase(); 80 if ( node.tagName === 'BUTTON' && /^(back|go back|vissza|view (posts|pages)|(all )?(posts|pages)|dashboard|bejegyzések|oldalak|vezérlőpult)/.test( label ) ) { 81 return true; 82 } 83 var href = node.getAttribute( 'href' ) || ''; 84 return node.tagName === 'A' && /(^|\/)edit\.php(\?|$)/.test( href.replace( /^https?:\/\/[^/]+/, '' ).replace( /^.*\/wp-admin\//, '' ) ); 85 } 86 function backNode( target ) { 87 var n = target && target.closest ? target.closest( 'a, button' ) : null; 88 return isBack( n ) ? n : null; 89 } 90 function onClick( e ) { 91 if ( backNode( e.target ) ) { 92 e.preventDefault(); 93 e.stopPropagation(); 94 window.location.href = back; 95 } 96 } 97 function relabel() { 98 document.querySelectorAll( HEADER ).forEach( function ( h ) { 99 h.querySelectorAll( 'a, button' ).forEach( function ( a ) { 100 if ( isBack( a ) && a.tagName === 'A' && a.getAttribute( 'href' ) !== back ) { 101 a.setAttribute( 'href', back ); 102 a.setAttribute( 'aria-label', 'Back to Clearyvo SEO' ); 103 } 104 } ); 105 } ); 106 } 107 document.addEventListener( 'click', onClick, true ); 108 relabel(); 109 var t = setInterval( relabel, 1500 ); 110 return function () { 111 document.removeEventListener( 'click', onClick, true ); 112 clearInterval( t ); 113 }; 114 }, [] ); 115 // Opened from the "Visual editor" buttons of the Clearyvo SEO screens (?cvseo=panel): show the panel once. 116 useEffect( function () { 117 if ( /[?&]cvseo=panel(&|$)/.test( window.location.search ) ) { 118 setTimeout( function () { 119 if ( ! panel.open ) { 120 panel.toggle(); 121 } 122 }, 300 ); 123 try { 124 window.history.replaceState( null, '', window.location.href.replace( /([?&])cvseo=panel(&|$)/, function ( m, a, b ) { 125 return b ? a : ''; 126 } ) ); 127 } catch ( e ) {} 128 } 486 129 }, [] ); 487 130 var editPost = useDispatch( 'core/editor' ).editPost; … … 519 162 return parse( debContent ); 520 163 }, [ debContent ] ); 164 165 var primary = parseInt( get( 'primary_cat' ), 10 ) || 0; 166 var catId = primary && data.cats.indexOf( primary ) !== -1 ? primary : data.cats[ 0 ]; 167 var dateText = ''; 168 if ( data.date && wp.date && wp.date.dateI18n ) { 169 try { 170 dateText = wp.date.dateI18n( wp.date.getSettings().formats.date, data.date ); 171 } catch ( e ) { 172 dateText = ''; 173 } 174 } 175 S.setLiveVars( { 176 excerpt: excerptOf( data.excerpt ? data.excerpt.replace( /<[^>]+>/g, '' ) : parsed.text ), 177 category: catId && cfg.cats[ catId ] ? cfg.cats[ catId ] : '', 178 author: extra.author, 179 date: dateText 180 } ); 521 181 522 182 var title = render( get( 'title' ) || cfg.template || '%title%', data.postTitle ); … … 546 206 547 207 var fields = el( C.PanelBody, { title: 'Search appearance', initialOpen: true }, 548 el( Preview, { title: title, desc: desc, link: data.link } ), 208 el( 'button', { type: 'button', className: 'cvseo-sb-previewbtn', title: 'Open the snippet editor', onClick: function () { 209 modalSt[ 1 ]( true ); 210 } }, el( Preview, { title: title, desc: desc, link: data.link } ) ), 211 el( C.Button, { variant: 'primary', className: 'cvseo-sb-editbtn', onClick: function () { 212 modalSt[ 1 ]( true ); 213 } }, 'Open the snippet editor' ), 549 214 el( 'div', { className: 'cvseo-sb-kwhead' }, 550 215 el( 'label', { htmlFor: 'cvseo-sb-kw' }, 'Focus phrase' ), … … 639 304 return el( Fragment, null, 640 305 el( PluginSidebarMoreMenuItem, { target: SIDEBAR, icon: el( Logo, { size: 20 } ) }, 'Clearyvo SEO' ), 641 el( PluginSidebar, { name: SIDEBAR, title: 'Clearyvo SEO', icon: el( ToolbarIcon, { status: result.status, score: result.score } ), className: 'cvseo-sb' }, 306 // The big "Edit SEO" header button opens this panel, so no second (pinned) icon on the toolbar. Only if the 307 // header button cannot be placed (unknown editor layout) the panel keeps its pin as the way in. 308 el( PluginSidebar, { name: SIDEBAR, title: 'Clearyvo SEO', icon: el( ToolbarIcon, { status: result.status, score: result.score } ), className: 'cvseo-sb', isPinnable: ! slot }, 642 309 summary, fields, panels, social, advanced 643 ) 310 ), 311 slot && wp.element.createPortal ? wp.element.createPortal( el( HeaderButton, { status: result.status, score: result.score, open: panel.open, onClick: panel.toggle } ), slot ) : null, 312 modalSt[ 0 ] ? el( SnippetEditor, { 313 mode: 'editor', 314 close: function () { 315 modalSt[ 1 ]( false ); 316 }, 317 get: get, set: set, title: title, desc: desc, link: data.link, slug: data.slug, 318 setSlug: function ( v ) { 319 editPost( { slug: v } ); 320 }, 321 postId: data.id, postTitle: data.postTitle, featured: extra.featured, score: result.score, status: result.status, 322 date: cfg.isPage ? '' : dateText, content: parsed 323 } ) : null 644 324 ); 645 325 } 646 326 327 /** Open / close state of the Clearyvo SEO panel (interface store; scope "core" since WordPress 6.6). */ 328 function usePanel() { 329 var id = 'clearyvo-seo/' + SIDEBAR; 330 var scope = wp.editor && wp.editor.PluginSidebar ? 'core' : 'core/edit-post'; 331 var active = useSelect( function ( select ) { 332 var s = select( 'core/interface' ); 333 return s && s.getActiveComplementaryArea ? s.getActiveComplementaryArea( scope ) : null; 334 }, [] ); 335 var open = active === id; 336 return { 337 open: open, 338 toggle: function () { 339 var d = wp.data.dispatch( 'core/interface' ); 340 var ep = wp.data.dispatch( 'core/edit-post' ); 341 if ( open ) { 342 if ( d && d.disableComplementaryArea ) { 343 d.disableComplementaryArea( scope ); 344 } else if ( ep && ep.closeGeneralSidebar ) { 345 ep.closeGeneralSidebar(); 346 } 347 } else if ( d && d.enableComplementaryArea ) { 348 d.enableComplementaryArea( scope, id ); 349 } else if ( ep && ep.openGeneralSidebar ) { 350 ep.openGeneralSidebar( id ); 351 } 352 } 353 }; 354 } 355 356 /** 357 * Opened from a Clearyvo SEO view (?cvseo_from=tab): the editor's own back button is replaced, through the 358 * MainDashboardButton slot of the editor, with one that goes back to that view. Older and newer editors without 359 * the slot are covered by the click handler in Sidebar. 360 */ 361 ( function () { 362 var m = window.location.search.match( /[?&]cvseo_from=([a-z_]+)/ ); 363 var ns = [ wp.editor, wp.editPost ].filter( function ( n ) { 364 return n && n.__experimentalMainDashboardButton; 365 } )[ 0 ]; 366 if ( ! m || ! cfg.backUrl || ! ns ) { 367 return; 368 } 369 var back = cfg.backUrl + '&tab=' + m[ 1 ]; 370 var Close = ( wp.editor && wp.editor.__experimentalFullscreenModeClose ) || ( wp.editPost && wp.editPost.__experimentalFullscreenModeClose ); 371 wp.plugins.registerPlugin( 'clearyvo-seo-back', { 372 render: function () { 373 return el( ns.__experimentalMainDashboardButton, null, 374 Close ? el( Close, { href: back, showTooltip: true } ) : 375 el( C.Button, { href: back, icon: 'arrow-left-alt2', label: 'Back to Clearyvo SEO', className: 'cvseo-back' } ) 376 ); 377 } 378 } ); 379 }() ); 380 647 381 wp.plugins.registerPlugin( 'clearyvo-seo', { render: Sidebar } ); 648 }( window.wp, window.cvseoSidebar ) );382 }( window.wp, window.cvseoSidebar, window.cvseoSnippet ) ); -
clearyvo-seo/trunk/seo/boot.php
r3788085 r3790680 21 21 return; 22 22 } 23 define( 'CVSEO_VERSION', '0. 5.3' );23 define( 'CVSEO_VERSION', '0.6.5' ); 24 24 define( 'CVSEO_DB_VERSION', '1' ); 25 25 define( 'CVSEO_FILE', $base_file ); … … 40 40 require_once CVSEO_DIR . 'admin/class-cvseo-metabox.php'; 41 41 require_once CVSEO_DIR . 'admin/class-cvseo-sidebar.php'; 42 require_once CVSEO_DIR . 'admin/class-cvseo-visual.php'; 42 43 } 43 44 … … 59 60 CVSEO_Metabox::init(); 60 61 CVSEO_Sidebar::init(); 62 CVSEO_Visual::init(); 61 63 } 62 64 }; -
clearyvo-seo/trunk/seo/includes/class-cvseo-rest.php
r3788085 r3790680 83 83 return current_user_can( 'edit_post', (int) $req['id'] ); 84 84 }, 85 ), 86 ) 87 ); 88 // Admin "Visual editor": page picker; the chosen page opens in the page editor with the Clearyvo SEO panel. 89 register_rest_route( 90 self::NS, 91 '/snippet-search', 92 array( 93 'methods' => 'GET', 94 'callback' => array( __CLASS__, 'search_snippets' ), 95 'permission_callback' => static function () { 96 return current_user_can( 'edit_posts' ); 97 }, 98 'args' => array( 99 's' => array( 100 'type' => 'string', 101 'default' => '', 102 ), 85 103 ), 86 104 ) … … 277 295 278 296 /** 297 * GET /snippet-search?s=: pages the user can edit, newest change first. 298 * 299 * @param WP_REST_Request $req Request. 300 * @return WP_REST_Response 301 */ 302 public static function search_snippets( WP_REST_Request $req ) { 303 $q = new WP_Query( 304 array( 305 'post_type' => CVSEO_Settings::post_types(), 306 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 307 's' => sanitize_text_field( (string) $req['s'] ), 308 'posts_per_page' => 30, 309 'orderby' => '' === (string) $req['s'] ? 'modified' : 'relevance', 310 'order' => 'DESC', 311 'no_found_rows' => true, 312 'update_post_meta_cache' => false, 313 'update_post_term_cache' => false, 314 ) 315 ); 316 $out = array(); 317 foreach ( $q->posts as $p ) { 318 if ( ! current_user_can( 'edit_post', $p->ID ) ) { 319 continue; 320 } 321 $pto = get_post_type_object( $p->post_type ); 322 $out[] = array( 323 'id' => $p->ID, 324 'title' => '' !== $p->post_title ? $p->post_title : __( '(no title)', 'clearyvo-seo' ), 325 'type' => $pto ? $pto->labels->singular_name : $p->post_type, 326 'status' => 'publish' === $p->post_status ? '' : $p->post_status, 327 'edit' => add_query_arg( 'cvseo', 'panel', get_edit_post_link( $p->ID, 'raw' ) ), 328 ); 329 } 330 return rest_ensure_response( array( 'items' => $out ) ); 331 } 332 333 /** 279 334 * GET /post/{id}. 280 335 *
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)