true, 'show_ui' => true], 'names'); unset($types['attachment']); return array_values(apply_filters('rankify_admin_column_post_types', $types)); } public function addColumns($columns) { if (!current_user_can('edit_posts')) { return $columns; } // Inserted before the date column rather than appended, so they do not end // up after whatever another plugin added last. $before = 'date'; $updated = []; foreach ($columns as $key => $label) { if ($key === $before) { $updated['rankify_seo'] = __('SEO', 'crownify'); if (self::linksAvailable()) { $updated['rankify_links'] = __('Links', 'crownify'); } } $updated[$key] = $label; } if (!isset($updated['rankify_seo'])) { $updated['rankify_seo'] = __('SEO', 'crownify'); if (self::linksAvailable()) { $updated['rankify_links'] = __('Links', 'crownify'); } } return $updated; } public function renderColumn($column, $postId) { if ('rankify_seo' === $column) { $this->renderSeoColumn((int) $postId); return; } if ('rankify_links' === $column) { $this->renderLinksColumn((int) $postId); } } // ──────────────────────────────────────────────────────────── // SEO status // ──────────────────────────────────────────────────────────── protected function renderSeoColumn($postId) { $status = self::status($postId); printf('%2$s', esc_attr($status['level']), esc_attr($status['label'])); } /** * @return array{level:string,label:string,issues:string[]} */ public static function status($postId) { $post = get_post((int) $postId); if (!($post instanceof \WP_Post)) { return ['level' => 'na', 'label' => __('Not analysed', 'crownify'), 'issues' => []]; } $context = self::contextFor($post); // Resolver memoizes per value name, not per object — without flushing, the // second row of the list would be handed the first row's title. Resolver::flush(); $robots = Resolver::robots($context); if (is_array($robots) && in_array('noindex', $robots, true)) { return ['level' => 'off', 'label' => __('Excluded from search (noindex)', 'crownify'), 'issues' => []]; } // Measured as plain text. Resolver returns real characters today, but an // override typed with an entity, or any HTML that reached the value, would // otherwise be counted at its markup length rather than its display length // and report a length nobody would recognise. $title = self::plain(Resolver::title($context)); Resolver::flush(); $description = self::plain(Resolver::description($context)); Resolver::flush(); $issues = []; $titleLength = mb_strlen($title); if (0 === $titleLength) { $issues[] = __('No title', 'crownify'); } elseif ($titleLength > self::TITLE_MAX) { $issues[] = __('Title too long', 'crownify'); } elseif ($titleLength < self::TITLE_MIN) { $issues[] = __('Title short', 'crownify'); } $descriptionLength = mb_strlen($description); if (0 === $descriptionLength) { // The one that actually costs traffic: with no description Google writes // its own from the page, and it is rarely the sentence you would pick. $issues[] = __('No meta description', 'crownify'); } elseif ($descriptionLength > self::DESCRIPTION_MAX) { $issues[] = __('Description too long', 'crownify'); } elseif ($descriptionLength < self::DESCRIPTION_MIN) { $issues[] = __('Description short', 'crownify'); } // Read through the same fallback as the title and description above. Only // Crownify's own key was checked here, so every post on a site migrating // from Yoast or Rank Math reported "no focus keyword" while the keyword // sat in the database being used by the analysis. $focus = get_post_meta($post->ID, Resolver::META_PREFIX . 'focus_keywords', true); if (!is_string($focus) || '' === trim($focus)) { $focus = LegacyMeta::forPost($post->ID, 'focus_keywords'); } if (!is_string($focus) || '' === trim($focus)) { $issues[] = __('No focus keyword', 'crownify'); } if (empty($issues)) { return ['level' => 'good', 'label' => __('Good: title, description and focus keyword all set', 'crownify'), 'issues' => []]; } // Missing or oversized output is a real problem; a short field or a missing // focus keyword is a nudge. Separating them is what keeps every row from // being amber, which is the state Yoast's column is criticised for. $serious = array_intersect($issues, [__('No title', 'crownify'), __('No meta description', 'crownify'), __('Title too long', 'crownify'), __('Description too long', 'crownify')]); return [ 'level' => empty($serious) ? 'warn' : 'bad', 'label' => implode(' · ', $issues), 'issues' => $issues, ]; } protected static function plain($value) { return trim(html_entity_decode(wp_strip_all_tags((string) $value), ENT_QUOTES, 'UTF-8')); } /** * A Context describing a post, built by hand. * * `Context::current()` reads the main query, which on an admin list screen * describes the list, not any post in it. */ protected static function contextFor(\WP_Post $post) { $context = new Context(); $context->type = Context::TYPE_SINGULAR; $context->object = $post; $context->objectId = (int) $post->ID; $context->postType = $post->post_type; $context->settingsKey = $post->post_type; return $context; } // ──────────────────────────────────────────────────────────── // Links // ──────────────────────────────────────────────────────────── protected static function linksAvailable() { return class_exists('\DaftPlug\Rankify\Features\Links\LinkGraph') && 'on' === Utils::getSetting('internalLinkSuggestions'); } protected function renderLinksColumn($postId) { if (!self::linksAvailable()) { echo '—'; return; } $graph = \DaftPlug\Rankify\Features\Links\LinkGraph::get(); // A post outside the scanned window has no entry, which is not the same as // having no links — saying "—" is honest, "0" would not be. if (!isset($graph['inbound'][$postId])) { printf('—', esc_attr__('Not included in the last link scan.', 'crownify')); return; } $outbound = isset($graph['outbound'][$postId]) ? count($graph['outbound'][$postId]) : 0; $inbound = count($graph['inbound'][$postId]); printf( '%s / %s', esc_attr( sprintf( /* translators: 1: number of outgoing links, 2: number of incoming links. */ __('%1$d internal links out, %2$d in', 'crownify'), $outbound, $inbound, ), ), esc_html(number_format_i18n($outbound)), 0 === $inbound ? 'rankify-col-orphan' : '', esc_html(number_format_i18n($inbound)), ); } /** * Rendered outside the plugin's own page shell, so the prefixed Tailwind * bundle never reaches it and these have to be inline — the same constraint * the metabox and the dashboard widget work under. */ public function columnStyles() { $screen = get_current_screen(); if (!$screen || 'edit' !== $screen->base || !in_array($screen->post_type, self::postTypes(), true)) { return; }?>