`, so every row works with JavaScript off. * * This is NOT an SEO audit. Requirements §16 rejects the content-audit * business outright. A check earns its place only if failing it stops an * engine reading, finding or identifying the site — which is why page speed, * backlinks and keyword gaps are not here and must not be added. * * @package Citolume */ defined( 'ABSPATH' ) || exit; /** * The groups, in reading order, and the keys each one owns. * * Identical to the platform's HEALTH_GROUPS. A check whose key is not listed * here still renders — it lands in the last group rather than vanishing — * because the API may add a check before this file knows about it. * * @return array */ function citolume_readiness_groups() { return array( array( 'title' => __( 'Access', 'citolume' ), 'keys' => array( 'crawlers', 'indexable', 'https', 'notfound' ), ), array( 'title' => __( 'Discovery', 'citolume' ), 'keys' => array( 'sitemap', 'sitemap_declared', 'sitemap_live', 'feed', 'llms' ), ), array( 'title' => __( 'Homepage identity', 'citolume' ), 'keys' => array( 'title', 'description', 'h1', 'canonical', 'entity', 'lang' ), ), ); } /** * The one-word state for a finding's chip, where the key alone cannot say it. * * The API sends its own `tag` when a check can fail in more than one way — an *

can be absent or there can be four of them, and "Missing" is wrong for * the second. This is only the fallback. * * @param string $key The check key. * @return string */ function citolume_readiness_tag( $key ) { $tags = array( 'crawlers' => __( 'Blocked', 'citolume' ), 'indexable' => __( 'Noindex', 'citolume' ), 'sitemap' => __( 'Missing', 'citolume' ), 'sitemap_declared' => __( 'Not declared', 'citolume' ), 'sitemap_live' => __( 'Dead pages', 'citolume' ), 'feed' => __( 'Missing', 'citolume' ), 'llms' => __( 'Missing', 'citolume' ), 'title' => __( 'Missing', 'citolume' ), 'description' => __( 'Missing', 'citolume' ), 'h1' => __( 'Missing', 'citolume' ), 'canonical' => __( 'Missing', 'citolume' ), 'entity' => __( 'Missing', 'citolume' ), 'lang' => __( 'Missing', 'citolume' ), 'notfound' => __( 'Always 200', 'citolume' ), 'https' => __( 'No redirect', 'citolume' ), ); return $tags[ $key ] ?? __( 'Needs attention', 'citolume' ); } /** * Where a finding can be fixed in this WordPress, when that place exists. * * The screen that actually holds the setting, not the page that shows the * symptom (30 Oct 2026: the tagline link took you to the field; the rest * only opened the front end). WordPress's own screens only — never another * plugin's, which may not be there and is not ours to depend on. Host- and * server-level findings (a 404 that answers 200, dead sitemap pages, a * missing feed, a canonical tag, schema) have no core setting and get no * link: a link to a screen that cannot help is worse than none. * * @param string $key The check key. * @return array{href: string, label: string}|null */ function citolume_readiness_help( $key ) { $front = 'page' === get_option( 'show_on_front' ) ? (int) get_option( 'page_on_front' ) : 0; $link = static function ( $path, $label ) { return array( 'href' => admin_url( $path ), 'label' => $label, ); }; switch ( $key ) { case 'llms': return $link( 'admin.php?page=citolume-llms', __( 'Open the llms.txt screen', 'citolume' ) ); case 'crawlers': case 'indexable': case 'sitemap': case 'sitemap_declared': // One switch behind all four: "Discourage search engines" blocks // crawlers in robots.txt, adds noindex and drops the sitemap. return $link( 'options-reading.php', __( 'Check “Search engine visibility” under Settings → Reading', 'citolume' ) ); case 'title': return $link( 'options-general.php', __( 'Set your Site Title under Settings → General', 'citolume' ) ); case 'description': return $link( 'options-general.php', __( 'Set your tagline under Settings → General', 'citolume' ) ); case 'h1': if ( $front ) { return array( 'href' => (string) get_edit_post_link( $front, 'raw' ), 'label' => __( 'Edit your homepage — give it one main heading', 'citolume' ), ); } return wp_is_block_theme() ? $link( 'site-editor.php', __( 'Edit the homepage template in the Site Editor', 'citolume' ) ) : $link( 'customize.php', __( 'Open the Customizer for your homepage', 'citolume' ) ); case 'lang': return $link( 'options-general.php#WPLANG', __( 'Choose the Site Language under Settings → General', 'citolume' ) ); case 'https': return $link( 'options-general.php', __( 'Make both addresses start with https:// under Settings → General', 'citolume' ) ); } return null; } /** * The filename behind an evidence URL — "robots.txt", not the whole address. * * @param string $url The fetched artifact. * @return string */ function citolume_readiness_evidence_label( $url ) { $path = (string) wp_parse_url( $url, PHP_URL_PATH ); $name = trim( $path, '/' ); if ( '' === $name ) { return (string) wp_parse_url( $url, PHP_URL_HOST ); } $parts = explode( '/', $name ); return (string) end( $parts ); } /** * The fix, as something to paste — the platform's `healthSnippet`. * * Only offered where a snippet is honest. `entity` is the careful one: it is * offered **only because the check failed**, which means the homepage carries * no identity block at all, so it cannot collide with one Yoast or Rank Math * already emits. That collision is the whole reason requirements §16 rules * out a schema *engine*; a block the owner pastes when there is nothing there * is a different thing. * * @param array $check The failing check. * @param string $domain The site's host. * @return array{label: string, code: string}|null */ function citolume_readiness_snippet( $check, $domain ) { $key = (string) ( $check['key'] ?? '' ); $head = __( 'Add inside <head>', 'citolume' ); if ( 'description' === $key ) { return array( 'label' => $head, 'code' => '', ); } if ( 'title' === $key ) { return array( 'label' => $head, 'code' => '' . sprintf( /* translators: %s: the site's domain. */ __( 'What %s is, in a few words', 'citolume' ), $domain ) . '', ); } if ( 'crawlers' === $key ) { // `tags` holds the crawlers this site permits, so the blocked ones are // the rest. A reading cached before that field existed carries no // tags: name every crawler we check rather than claim none is blocked. $allowed = array_map( 'strval', (array) ( $check['tags'] ?? array() ) ); $blocked = array_values( array_diff( array_keys( citolume_reachability_agents() ), $allowed ) ); if ( ! $blocked ) { return null; } $lines = array(); foreach ( $blocked as $crawler ) { $lines[] = "User-agent: {$crawler}\nAllow: /"; } return array( 'label' => __( 'Add to robots.txt', 'citolume' ), 'code' => implode( "\n\n", $lines ), ); } if ( 'canonical' === $key ) { return array( 'label' => $head, 'code' => '', ); } if ( 'lang' === $key ) { return array( 'label' => __( 'On the <html> element', 'citolume' ), 'code' => '', ); } if ( 'sitemap_declared' === $key ) { return array( 'label' => __( 'Add to robots.txt', 'citolume' ), 'code' => 'Sitemap: https://' . $domain . '/sitemap.xml', ); } if ( 'entity' === $key ) { return array( 'label' => $head, 'code' => "", ); } return null; } /** * Handles the "Check now" post. * * @return void */ function citolume_handle_readiness_post() { if ( ! isset( $_POST['citolume_readiness_action'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } check_admin_referer( 'citolume_readiness' ); // The service reads the site about fifteen ways before answering — 6.4 s // on natprooverseas.com, measured — so the 5 s default timed out. $result = citolume_api_send( '/v1/health/run', 'POST', array(), 45 ); if ( is_wp_error( $result ) ) { add_settings_error( 'citolume', 'citolume_readiness', $result->get_error_message() ); return; } delete_transient( 'citolume_readiness' ); add_settings_error( 'citolume', 'citolume_readiness', __( 'Checks complete — every verdict below is from a read of your site just now.', 'citolume' ), 'success' ); } /** * The stored reading, cached briefly so opening the screen twice is one call. * * @param bool $fresh Skip the transient. * @return array|WP_Error */ function citolume_readiness_reading( $fresh = false ) { if ( ! $fresh ) { $cached = get_transient( 'citolume_readiness' ); if ( false !== $cached ) { return $cached; } } $body = citolume_api_get( '/v1/health' ); if ( is_wp_error( $body ) ) { return $body; } set_transient( 'citolume_readiness', $body, 5 * MINUTE_IN_SECONDS ); return $body; } /** * One check, in the one shape every check uses. * * Pass, finding and "could not tell" differ by the mark and the chip, never * by layout — the 8 October design's rule (ticket 097). Eight equal rows gave * the one thing to do the same weight as the seven things already done, so * findings are lifted to the top of the page; the row itself stays one shape * so a reader scanning the column sees one rhythm. * * **A row with nothing more to say does not pretend to open.** The 404 probe * and the https redirect carry no link, no chips and no snippet, so they * render flat rather than expanding onto an empty panel. * * @param array $check The check, as the API sends it. * @param string $domain The site's host, for the snippet. * @param string $open The check key the pips asked to open, if any. * @return void */ function citolume_render_readiness_row( $check, $domain = '', $open = '' ) { $ok = $check['ok'] ?? null; $pass = true === $ok; $unknown = null === $ok; $state = $pass ? 'is-pass' : ( $unknown ? 'is-unknown' : 'is-bad' ); $mark = $pass ? '✓' : ( $unknown ? '?' : '!' ); $key = (string) ( $check['key'] ?? '' ); $help = $pass || $unknown ? null : citolume_readiness_help( $key ); $tags = (array) ( $check['tags'] ?? array() ); $url = (string) ( $check['url'] ?? '' ); $fix = $pass || $unknown ? '' : (string) ( $check['fix'] ?? '' ); $snippet = $pass || $unknown ? null : citolume_readiness_snippet( $check, $domain ); $has_panel = '' !== $fix || $tags || '' !== $url || null !== $help; ob_start(); ?>

%s', esc_attr( $state ), esc_attr( $key ), wp_kses_post( $head ) ); return; } ?>
>

get_error_message() ); ?>

', 'citolume' ), __( 'Canonical URL', 'citolume' ), __( 'Who you are (schema)', 'citolume' ), __( 'Language', 'citolume' ) ), ); ?>

$group ) : ?>

false === ( $c['ok'] ?? null ) ) ); $unknown = array_values( array_filter( $checks, static fn( $c ) => null === ( $c['ok'] ?? null ) ) ); $total = count( $checks ); $passed = $total - count( $failing ) - count( $unknown ); if ( $failing ) { $headline = sprintf( /* translators: %d: number of checks needing attention. */ _n( '%d check needs attention', '%d checks need attention', count( $failing ), 'citolume' ), count( $failing ) ); } elseif ( $unknown ) { $headline = sprintf( /* translators: %d: number of checks that could not be judged. */ _n( '%d check could not be judged', '%d checks could not be judged', count( $unknown ), 'citolume' ), count( $unknown ) ); } else { $headline = __( 'Every check passing', 'citolume' ); } // Passing checks, grouped. Anything whose key no group claims lands in // the last one, so a check the API adds before this file knows about // it is still shown. $groups = citolume_readiness_groups(); $mapped = array_merge( ...array_column( $groups, 'keys' ) ); $by_key = array(); foreach ( $checks as $check ) { $by_key[ (string) ( $check['key'] ?? '' ) ] = $check; } $passing_groups = array(); $last = count( $groups ) - 1; foreach ( $groups as $i => $group ) { $keys = $group['keys']; if ( $i === $last ) { foreach ( array_keys( $by_key ) as $key ) { if ( ! in_array( $key, $mapped, true ) ) { $keys[] = $key; } } } $rows = array(); foreach ( $keys as $key ) { if ( isset( $by_key[ $key ] ) && true === ( $by_key[ $key ]['ok'] ?? null ) ) { $rows[] = $by_key[ $key ]; } } if ( $rows ) { $passing_groups[] = array( 'title' => $group['title'], 'checks' => $rows, ); } } /* * The order the page reads in — findings first, then the passing * groups — and the pips read from this same list (ticket 098). * * They were in run order once while the rows were in group order with * the failures lifted out, so an orange pip sat at position 10 while * the finding it stood for was the first row on the page. A progress * bar whose segments do not correspond to anything visible is * decoration. */ $ordered = array_merge( $failing, $unknown ); foreach ( $passing_groups as $group ) { $ordered = array_merge( $ordered, $group['checks'] ); } // Which row a pip asked for. Narrowed to a key this reading actually // holds, so the parameter cannot put arbitrary text into an id. $open = isset( $_GET['open'] ) ? sanitize_key( wp_unslash( $_GET['open'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only disclosure state. if ( '' !== $open && ! isset( $by_key[ $open ] ) ) { $open = ''; } ?>
/