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;
}?>