Plugin Directory

Changeset 3790680


Ignore:
Timestamp:
11/08/2026 08:24:08 AM (5 hours ago)
Author:
kishollo001
Message:

Release 0.6.5: Visual editor opens pages with the SEO panel, back to Clearyvo SEO, Image ALT scan filter, one button style

Location:
clearyvo-seo
Files:
10 added
22 edited
1 copied

Legend:

Unmodified
Added
Removed
  • clearyvo-seo/tags/0.6.5/assets/style.css

    r3787975 r3790680  
    7676.wpsc-table td{vertical-align:top}
    7777@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  
    44 * Plugin URI:        https://wp-sitechecker.com/
    55 * 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.3
     6 * Version:           0.6.5
    77 * Requires at least: 6.2
    88 * Requires PHP:      7.4
  • clearyvo-seo/tags/0.6.5/includes/class-wpsc-admin.php

    r3787975 r3790680  
    3737            'alt_part' => $part($gap, $img), 'h_part' => $part($hp, $np), 'm_part' => $part($mp, $np),
    3838            '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()),
    3940            'headings_rows' => $hrows + count($h['site']), 'keywords_rows' => $krows, 'meta_rows' => $mrows, 'links_rows' => count($l['orphans']) + count($l['menuOnly']),
    4041        ];
    … …  
    283284        $type = WPSiteChecker_UI::filter_value('type', array_keys($types));
    284285        $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']);
    285289        $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);
    288294
    289295        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'),
    … …  
    307313        WPSiteChecker_UI::filter_bar([
    308314            '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]],
    310318            // Checked in the browser by app.js (the results of each page are computed there): a local checkbox.
    311319            'checks'  => [['id' => 'wpsc-only-problems', 'label' => __('Show only pages with problems', 'clearyvo-seo'), 'local' => true]],
    … …  
    314322        echo '<p id="wpsc-summary" class="wpsc-summary" role="status" aria-live="polite"></p><div id="wpsc-sitewide"></div>';
    315323        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']));
    317325            else WPSiteChecker_UI::empty_state(['title' => __('No published pages', 'clearyvo-seo'), 'text' => __('Publish a page or post: its images are checked here.', 'clearyvo-seo')]);
    318326            return;
    … …  
    330338                . '<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>'
    331339                . '<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>' : '')
    332341                . '<div class="wpsc-checks"></div></div>'
    333342                . '<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>'
    … …  
    337346        wp_reset_postdata();
    338347        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');
    339358    }
    340359
  • clearyvo-seo/tags/0.6.5/includes/class-wpsc-audit.php

    r3787975 r3790680  
    7171    static function flush() { self::$cache = null; }
    7272
     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
    7384    static function lc($s) { return function_exists('mb_strtolower') ? mb_strtolower((string) $s, 'UTF-8') : strtolower((string) $s); }
    7485    /** 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  
    44/**
    55 * 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.
    78 */
    89final class WPSC_Views {
    … …  
    1516        $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>';
    1617        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
    1719        return $h;
    1820    }
    … …  
    6769        if (!$can) $note .= ' ' . __('An editor or administrator can scan the site.', 'clearyvo-seo');
    6870        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')]] : []),
    7073            'status'    => WPSiteChecker_UI::last_run_text($ts, 'scan'),
    7174            'status_id' => 'wpsc-audit-status',
    … …  
    114117        $cards = [
    115118            ['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']],
    117120                [$nokwAlt, __('pages where no image ALT contains the focus keyword', 'clearyvo-seo'), 'notice']]],
    118121            ['headings', __('Headings', 'clearyvo-seo'), [
    … …  
    148151                $n = (int) $line[0];
    149152                $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]) : []];
    151155            }
    152156        }
    … …  
    233237        WPSiteChecker_UI::cards([
    234238            ['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']) : ''],
    236240            ['label' => __('Pages with heading problems', 'clearyvo-seo'), 'value' => $s ? $s['hp'] : null, 'tone' => 'warning', 'url' => $s && $s['hp'] ? WPSC_Admin::url('headings') : ''],
    237241            ['label' => __('Shared focus keywords', 'clearyvo-seo'), 'value' => $s ? $s['conflicts'] : null, 'tone' => 'warning', 'url' => $s && $s['conflicts'] ? WPSC_Admin::url('keywords') : ''],
    … …  
    263267                    $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>'
    264268                        . '<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>';
    266270                }
    267271                WPSiteChecker_UI::html($h . '</tbody>');
    … …  
    304308        if (!array_filter(array_column($rows, 'issues'))) { if (!$h['site']) WPSiteChecker_UI::empty_clean(WPSC_Admin::stats()['ts'], 'scan'); return; }
    305309        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
    306311        $popts = ['' => __('All problems', 'clearyvo-seo')];
    307312        foreach ($labels as $k => $l) $popts[$k] = $l[0];
    … …  
    414419        $rows = WPSC_Audit::meta();
    415420        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
    416422        $popts = ['' => __('All problems', 'clearyvo-seo')];
    417423        foreach ($labels as $k => $l) $popts[$k] = $l[0];
  • clearyvo-seo/tags/0.6.5/readme.txt

    r3788085 r3790680  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.5.3
     7Stable tag: 0.6.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    125125
    126126== 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.
    127156
    128157= 0.5.3 =
    … …  
    248277== Upgrade Notice ==
    249278
     279= 0.6.5 =
     280Back button of the page editor returns to Clearyvo SEO when opened from there.
     281
     282= 0.6.4 =
     283Back button of the page editor returns to Clearyvo SEO when opened from there.
     284
     285= 0.6.3 =
     286Image ALT tab: count, "Missing ALT in the last scan" filter and per-page marks. Settings and data are unchanged.
     287
     288= 0.6.2 =
     289Visual 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 =
     292Visual 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 =
     295New visual snippet editor: "Edit SEO" button next to Publish, desktop / mobile Google preview, variable chips, social card previews. Settings and data are unchanged.
     296
    250297= 0.5.0 =
    251298Unified 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  
    11<?php
    22/**
    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.
    45 *
    56 * @package ClearyvoSEO
    … …  
    2930
    3031        /**
     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        /**
    3173         * Script and data.
    3274         */
    … …  
    4385                $template = $ctx && $ctx->is_front ? CVSEO_Settings::get( 'title_home' ) : CVSEO_Settings::title_template_pt( $post->post_type );
    4486                $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                        }
    4791                }
     92                self::register_core();
     93                $deps[] = 'cvseo-snippet-core';
    4894                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 );
    5096
    5197                $cats = array();
    … …  
    58104                        'cvseo-sidebar',
    59105                        '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                                )
    79114                        )
    80115                );
  • clearyvo-seo/tags/0.6.5/seo/assets/css/sidebar.css

    r3787975 r3790680  
    6666.cvseo-sb .components-base-control { margin-bottom: 16px; }
    6767.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  
    11/**
    2  * Clearyvo SEO – block editor sidebar.
     2 * Clearyvo SEO – block editor panel.
    33 * 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).
    56 */
    6 ( function ( wp, cfg ) {
     7( function ( wp, cfg, S ) {
    78        'use strict';
    8         if ( ! wp || ! wp.plugins || ! cfg ) {
     9        if ( ! wp || ! wp.plugins || ! cfg || ! S ) {
    910                return;
    1011        }
     12        S.configure( cfg );
    1113        var el = wp.element.createElement;
    1214        var Fragment = wp.element.Fragment;
    … …  
    2224
    2325        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;
    47231
    47332        function Sidebar() {
    … …  
    48241                                link: ed.getPermalink ? ed.getPermalink() : '',
    48342                                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
    48547                        };
     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                        }
    486129                }, [] );
    487130                var editPost = useDispatch( 'core/editor' ).editPost;
    … …  
    519162                        return parse( debContent );
    520163                }, [ 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                } );
    521181
    522182                var title = render( get( 'title' ) || cfg.template || '%title%', data.postTitle );
    … …  
    546206
    547207                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' ),
    549214                        el( 'div', { className: 'cvseo-sb-kwhead' },
    550215                                el( 'label', { htmlFor: 'cvseo-sb-kw' }, 'Focus phrase' ),
    … …  
    639304                return el( Fragment, null,
    640305                        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 },
    642309                                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
    644324                );
    645325        }
    646326
     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
    647381        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  
    2121                        return;
    2222                }
    23                 define( 'CVSEO_VERSION', '0.5.3' );
     23                define( 'CVSEO_VERSION', '0.6.5' );
    2424                define( 'CVSEO_DB_VERSION', '1' );
    2525                define( 'CVSEO_FILE', $base_file );
    … …  
    4040                        require_once CVSEO_DIR . 'admin/class-cvseo-metabox.php';
    4141                        require_once CVSEO_DIR . 'admin/class-cvseo-sidebar.php';
     42                        require_once CVSEO_DIR . 'admin/class-cvseo-visual.php';
    4243                }
    4344
    … …  
    5960                                CVSEO_Metabox::init();
    6061                                CVSEO_Sidebar::init();
     62                                CVSEO_Visual::init();
    6163                        }
    6264                };
  • clearyvo-seo/tags/0.6.5/seo/includes/class-cvseo-rest.php

    r3788085 r3790680  
    8383                                                return current_user_can( 'edit_post', (int) $req['id'] );
    8484                                        },
     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                                        ),
    85103                                ),
    86104                        )
    … …  
    277295
    278296        /**
     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        /**
    279334         * GET /post/{id}.
    280335         *
  • clearyvo-seo/trunk/assets/style.css

    r3787975 r3790680  
    7676.wpsc-table td{vertical-align:top}
    7777@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  
    44 * Plugin URI:        https://wp-sitechecker.com/
    55 * 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.3
     6 * Version:           0.6.5
    77 * Requires at least: 6.2
    88 * Requires PHP:      7.4
  • clearyvo-seo/trunk/includes/class-wpsc-admin.php

    r3787975 r3790680  
    3737            'alt_part' => $part($gap, $img), 'h_part' => $part($hp, $np), 'm_part' => $part($mp, $np),
    3838            '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()),
    3940            'headings_rows' => $hrows + count($h['site']), 'keywords_rows' => $krows, 'meta_rows' => $mrows, 'links_rows' => count($l['orphans']) + count($l['menuOnly']),
    4041        ];
    … …  
    283284        $type = WPSiteChecker_UI::filter_value('type', array_keys($types));
    284285        $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']);
    285289        $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);
    288294
    289295        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'),
    … …  
    307313        WPSiteChecker_UI::filter_bar([
    308314            '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]],
    310318            // Checked in the browser by app.js (the results of each page are computed there): a local checkbox.
    311319            'checks'  => [['id' => 'wpsc-only-problems', 'label' => __('Show only pages with problems', 'clearyvo-seo'), 'local' => true]],
    … …  
    314322        echo '<p id="wpsc-summary" class="wpsc-summary" role="status" aria-live="polite"></p><div id="wpsc-sitewide"></div>';
    315323        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']));
    317325            else WPSiteChecker_UI::empty_state(['title' => __('No published pages', 'clearyvo-seo'), 'text' => __('Publish a page or post: its images are checked here.', 'clearyvo-seo')]);
    318326            return;
    … …  
    330338                . '<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>'
    331339                . '<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>' : '')
    332341                . '<div class="wpsc-checks"></div></div>'
    333342                . '<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>'
    … …  
    337346        wp_reset_postdata();
    338347        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');
    339358    }
    340359
  • clearyvo-seo/trunk/includes/class-wpsc-audit.php

    r3787975 r3790680  
    7171    static function flush() { self::$cache = null; }
    7272
     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
    7384    static function lc($s) { return function_exists('mb_strtolower') ? mb_strtolower((string) $s, 'UTF-8') : strtolower((string) $s); }
    7485    /** Lower case, accents removed, single spaces: "Generálkivitelezés  Budapest" == "generalkivitelezes budapest". */
  • clearyvo-seo/trunk/includes/class-wpsc-views.php

    r3787975 r3790680  
    44/**
    55 * 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.
    78 */
    89final class WPSC_Views {
    … …  
    1516        $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>';
    1617        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
    1719        return $h;
    1820    }
    … …  
    6769        if (!$can) $note .= ' ' . __('An editor or administrator can scan the site.', 'clearyvo-seo');
    6870        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')]] : []),
    7073            'status'    => WPSiteChecker_UI::last_run_text($ts, 'scan'),
    7174            'status_id' => 'wpsc-audit-status',
    … …  
    114117        $cards = [
    115118            ['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']],
    117120                [$nokwAlt, __('pages where no image ALT contains the focus keyword', 'clearyvo-seo'), 'notice']]],
    118121            ['headings', __('Headings', 'clearyvo-seo'), [
    … …  
    148151                $n = (int) $line[0];
    149152                $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]) : []];
    151155            }
    152156        }
    … …  
    233237        WPSiteChecker_UI::cards([
    234238            ['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']) : ''],
    236240            ['label' => __('Pages with heading problems', 'clearyvo-seo'), 'value' => $s ? $s['hp'] : null, 'tone' => 'warning', 'url' => $s && $s['hp'] ? WPSC_Admin::url('headings') : ''],
    237241            ['label' => __('Shared focus keywords', 'clearyvo-seo'), 'value' => $s ? $s['conflicts'] : null, 'tone' => 'warning', 'url' => $s && $s['conflicts'] ? WPSC_Admin::url('keywords') : ''],
    … …  
    263267                    $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>'
    264268                        . '<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>';
    266270                }
    267271                WPSiteChecker_UI::html($h . '</tbody>');
    … …  
    304308        if (!array_filter(array_column($rows, 'issues'))) { if (!$h['site']) WPSiteChecker_UI::empty_clean(WPSC_Admin::stats()['ts'], 'scan'); return; }
    305309        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
    306311        $popts = ['' => __('All problems', 'clearyvo-seo')];
    307312        foreach ($labels as $k => $l) $popts[$k] = $l[0];
    … …  
    414419        $rows = WPSC_Audit::meta();
    415420        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
    416422        $popts = ['' => __('All problems', 'clearyvo-seo')];
    417423        foreach ($labels as $k => $l) $popts[$k] = $l[0];
  • clearyvo-seo/trunk/readme.txt

    r3788085 r3790680  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.5.3
     7Stable tag: 0.6.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    125125
    126126== 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.
    127156
    128157= 0.5.3 =
    … …  
    248277== Upgrade Notice ==
    249278
     279= 0.6.5 =
     280Back button of the page editor returns to Clearyvo SEO when opened from there.
     281
     282= 0.6.4 =
     283Back button of the page editor returns to Clearyvo SEO when opened from there.
     284
     285= 0.6.3 =
     286Image ALT tab: count, "Missing ALT in the last scan" filter and per-page marks. Settings and data are unchanged.
     287
     288= 0.6.2 =
     289Visual 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 =
     292Visual 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 =
     295New visual snippet editor: "Edit SEO" button next to Publish, desktop / mobile Google preview, variable chips, social card previews. Settings and data are unchanged.
     296
    250297= 0.5.0 =
    251298Unified 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  
    11<?php
    22/**
    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.
    45 *
    56 * @package ClearyvoSEO
    … …  
    2930
    3031        /**
     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        /**
    3173         * Script and data.
    3274         */
    … …  
    4385                $template = $ctx && $ctx->is_front ? CVSEO_Settings::get( 'title_home' ) : CVSEO_Settings::title_template_pt( $post->post_type );
    4486                $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                        }
    4791                }
     92                self::register_core();
     93                $deps[] = 'cvseo-snippet-core';
    4894                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 );
    5096
    5197                $cats = array();
    … …  
    58104                        'cvseo-sidebar',
    59105                        '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                                )
    79114                        )
    80115                );
  • clearyvo-seo/trunk/seo/assets/css/sidebar.css

    r3787975 r3790680  
    6666.cvseo-sb .components-base-control { margin-bottom: 16px; }
    6767.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  
    11/**
    2  * Clearyvo SEO – block editor sidebar.
     2 * Clearyvo SEO – block editor panel.
    33 * 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).
    56 */
    6 ( function ( wp, cfg ) {
     7( function ( wp, cfg, S ) {
    78        'use strict';
    8         if ( ! wp || ! wp.plugins || ! cfg ) {
     9        if ( ! wp || ! wp.plugins || ! cfg || ! S ) {
    910                return;
    1011        }
     12        S.configure( cfg );
    1113        var el = wp.element.createElement;
    1214        var Fragment = wp.element.Fragment;
    … …  
    2224
    2325        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;
    47231
    47332        function Sidebar() {
    … …  
    48241                                link: ed.getPermalink ? ed.getPermalink() : '',
    48342                                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
    48547                        };
     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                        }
    486129                }, [] );
    487130                var editPost = useDispatch( 'core/editor' ).editPost;
    … …  
    519162                        return parse( debContent );
    520163                }, [ 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                } );
    521181
    522182                var title = render( get( 'title' ) || cfg.template || '%title%', data.postTitle );
    … …  
    546206
    547207                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' ),
    549214                        el( 'div', { className: 'cvseo-sb-kwhead' },
    550215                                el( 'label', { htmlFor: 'cvseo-sb-kw' }, 'Focus phrase' ),
    … …  
    639304                return el( Fragment, null,
    640305                        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 },
    642309                                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
    644324                );
    645325        }
    646326
     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
    647381        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  
    2121                        return;
    2222                }
    23                 define( 'CVSEO_VERSION', '0.5.3' );
     23                define( 'CVSEO_VERSION', '0.6.5' );
    2424                define( 'CVSEO_DB_VERSION', '1' );
    2525                define( 'CVSEO_FILE', $base_file );
    … …  
    4040                        require_once CVSEO_DIR . 'admin/class-cvseo-metabox.php';
    4141                        require_once CVSEO_DIR . 'admin/class-cvseo-sidebar.php';
     42                        require_once CVSEO_DIR . 'admin/class-cvseo-visual.php';
    4243                }
    4344
    … …  
    5960                                CVSEO_Metabox::init();
    6061                                CVSEO_Sidebar::init();
     62                                CVSEO_Visual::init();
    6163                        }
    6264                };
  • clearyvo-seo/trunk/seo/includes/class-cvseo-rest.php

    r3788085 r3790680  
    8383                                                return current_user_can( 'edit_post', (int) $req['id'] );
    8484                                        },
     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                                        ),
    85103                                ),
    86104                        )
    … …  
    277295
    278296        /**
     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        /**
    279334         * GET /post/{id}.
    280335         *
Note: See TracChangeset for help on using the changeset viewer.