settings = $settings; } /** * Register hooks. */ public function hooks() { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_filter( 'admin_body_class', array( $this, 'body_class' ) ); } /** * Admin tabs. Filterable so Pro can inject tabs; free renders its own. * * @return array slug => label, or slug => array( label, icon ). */ public function tabs() { $tabs = array( 'overview' => __( 'Overview', 'oorank' ), 'access' => __( 'AI Access', 'oorank' ), 'llms' => __( 'llms.txt', 'oorank' ), 'schema' => __( 'Schema', 'oorank' ), 'audit' => __( 'Audit', 'oorank' ), 'tools' => __( 'Connect', 'oorank' ), 'advanced' => __( 'Advanced', 'oorank' ), ); /** * Filter the Oorank admin tabs. * * Extension seam: Pro appends tabs (Analytics, Citations…). A tab may be * a plain label, or an array of `label` and an `icon` name from the * shell's set (chart, quote, bag, key, bot, file, gauge, sparkle, * sliders, link). A name rather than markup, because an add-on that could * hand the shell an SVG could hand it anything; an unknown name falls * back to the generic icon. * * @param array $tabs slug => label, or slug => array( label, icon ). * @param Oorank_Admin $admin This controller. */ return apply_filters( 'oorank_admin_tabs', $tabs, $this ); } /** * The tab being viewed, defaulting to Overview. * * @return string */ private function active_tab() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch, no state change. $active = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; $tabs = $this->tabs(); return isset( $tabs[ $active ] ) ? $active : 'overview'; } /** * Register the top-level menu. */ public function register_menu() { add_menu_page( __( 'Oorank', 'oorank' ), __( 'Oorank', 'oorank' ), Oorank_Roles::cap(), self::MENU_SLUG, array( $this, 'render' ), OORANK_URL . 'admin/images/menu-icon.svg', // Just under Dashboard, above the first separator at 4. A // fraction rather than a round 3, so a plugin that already // claimed that slot is not overwritten. 3.7 ); } /** * Mark this plugin's screens so the stylesheet can reclaim the canvas from * wp-admin's gutter without affecting any other page. * * @param string $classes Space-separated body classes. * @return string */ public function body_class( $classes ) { if ( $this->is_plugin_screen() ) { $classes .= ' oorank-screen'; } return $classes; } /** * Whether the current request is one of this plugin's own screens. * * @return bool */ private function is_plugin_screen() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; return $screen && false !== strpos( (string) $screen->id, self::MENU_SLUG ); } /** * Enqueue admin assets on this plugin's screens only. * * @param string $hook Current admin page hook. */ public function enqueue( $hook ) { if ( false === strpos( (string) $hook, self::MENU_SLUG ) ) { return; } // The Schema tab picks a publisher logo out of the media library. wp_enqueue_media(); self::enqueue_app(); self::localize( 'oorank-app', $this->boot() ); } /** * Enqueue the built app bundle and its stylesheet. * * Shared with the post-edit meta box, which mounts the same bundle for its * single audit panel rather than shipping a second renderer. */ public static function enqueue_app() { $asset_file = OORANK_PATH . 'admin/dist/index.asset.php'; $asset = is_readable( $asset_file ) ? require $asset_file : array( 'dependencies' => array( 'wp-element', 'wp-api-fetch' ), 'version' => OORANK_VERSION, ); wp_enqueue_style( 'oorank-app', OORANK_URL . 'admin/dist/style-index.css', array(), $asset['version'] ); // The build emits a mirrored stylesheet; hand it to RTL locales. wp_style_add_data( 'oorank-app', 'rtl', 'replace' ); wp_enqueue_script( 'oorank-app', OORANK_URL . 'admin/dist/index.js', $asset['dependencies'], $asset['version'], true ); } /** * Hand the app its REST root, nonce, translated strings and server context. * * @param string $handle Registered script handle. * @param array $boot Bootstrap payload. */ private static function localize( $handle, array $boot ) { wp_localize_script( $handle, 'OorankAdmin', array( 'root' => esc_url_raw( rest_url( Oorank_Rest::NAMESPACE ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'i18n' => self::strings(), 'boot' => $boot, ) ); } /** * Enqueue assets for the post-edit meta box. * * The meta box needs the renderer and the strings, but none of the * site-wide context the settings screens use. * * @param array $boot Bootstrap payload for the meta box. */ public static function enqueue_meta_box( array $boot ) { self::enqueue_app(); self::localize( 'oorank-app', $boot ); } /** * Server context printed with the script, so the first paint needs no * round trip. Everything here is cheap to compute — nothing in this method * may trigger an llms.txt build or an audit fetch. * * @return array */ private function boot() { $settings = $this->settings->get(); $bots = oorank()->bots; $llms = oorank()->llms_txt; $audit = oorank()->audit; $registry = array(); foreach ( Oorank_Bot_Manager::registry() as $slug => $entry ) { $registry[] = array( 'slug' => $slug, 'name' => $entry['name'], 'operator' => $entry['operator'], 'docs' => $entry['docs'], 'category' => $entry['category'], 'categoryLabel' => Oorank_Bot_Manager::category_label( $entry['category'] ), ); } $post_types = array(); foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $post_type ) { if ( 'attachment' === $post_type->name ) { continue; } $post_types[] = array( 'slug' => $post_type->name, 'label' => $post_type->labels->name, 'count' => (int) wp_count_posts( $post_type->name )->publish, ); } // Transients only — reading the built files must never build them. $index = get_transient( Oorank_Llms_Txt::CACHE_INDEX ); $full = get_transient( Oorank_Llms_Txt::CACHE_FULL ); return array( 'settings' => $settings, 'tabs' => $this->tab_list(), 'activeTab' => $this->active_tab(), 'bots' => $registry, 'presets' => array( 'active' => $bots->active_preset(), ), 'postTypes' => $post_types, 'report' => $audit->report(), 'scannable' => count( $audit->scannable_ids() ), 'checks' => $this->check_list(), 'schema' => array( 'emitters' => array_values( Oorank_Schema::active_emitters() ), 'suppressed' => Oorank_Schema::assumed_foreign_slots(), 'logo' => $this->logo_preview( $settings ), 'fallbackUrl' => $this->fallback_logo_url(), 'authors' => $this->author_coverage(), 'usersUrl' => admin_url( 'users.php' ), ), 'mcp' => oorank()->mcp->status(), 'roles' => $this->role_list(), 'llms' => array( 'indexBytes' => is_string( $index ) ? strlen( $index ) : null, 'fullBytes' => is_string( $full ) ? strlen( $full ) : null, 'available' => $llms->rewrites_available(), 'languages' => $this->language_endpoints(), ), 'permalinks' => Oorank_Permalinks::status(), 'env' => array( 'prettyPermalinks' => (bool) get_option( 'permalink_structure' ), 'staticRobots' => $bots->has_static_robots(), 'robotsFile' => $bots->robots_file(), 'version' => OORANK_VERSION, 'siteName' => get_bloginfo( 'name' ), ), 'urls' => array( 'home' => home_url( '/' ), 'llms' => home_url( '/llms.txt' ), 'llmsFull' => home_url( '/llms-full.txt' ), 'robots' => add_query_arg( 'robots', '1', home_url( '/' ) ), 'permalink' => admin_url( 'options-permalink.php' ), 'admin' => add_query_arg( 'page', self::MENU_SLUG, admin_url( 'admin.php' ) ), ), 'pro' => Oorank_Addon_Installer::status(), 'capability' => current_user_can( 'manage_options' ), ); } /** * How many publishing authors have filled in at least one profile URL. * * A byline only resolves to an entity if it points somewhere, so this is * the one number that tells an owner whether F5 is doing anything. Bounded * so the bootstrap payload stays cheap on sites with many users. * * @return array{linked:int, total:int} */ private function author_coverage() { $users = get_users( array( 'capability' => 'edit_posts', 'fields' => 'ID', 'number' => 50, ) ); $keys = array_keys( Oorank_Schema::profile_fields() ); $keys[] = 'user_url'; $linked = 0; foreach ( $users as $user_id ) { foreach ( $keys as $key ) { if ( '' !== trim( (string) get_the_author_meta( $key, $user_id ) ) ) { ++$linked; break; } } } return array( 'linked' => $linked, 'total' => count( $users ), ); } /** * URL of the configured publisher logo, when one is set. * * @param array $settings Current settings. * @return string|null */ private function logo_preview( array $settings ) { $id = isset( $settings['schema']['logo'] ) ? absint( $settings['schema']['logo'] ) : 0; if ( ! $id ) { return null; } $src = wp_get_attachment_image_src( $id, 'medium' ); return ( $src && ! empty( $src[0] ) ) ? $src[0] : null; } /** * The logo the schema falls back to when none is chosen — the theme's * custom logo, then the site icon. Shown so the screen can say what will * be published rather than leaving the field looking empty. * * @return string|null */ private function fallback_logo_url() { $id = (int) get_theme_mod( 'custom_logo' ); if ( ! $id ) { $id = (int) get_option( 'site_icon' ); } if ( ! $id ) { return null; } $src = wp_get_attachment_image_src( $id, 'medium' ); return ( $src && ! empty( $src[0] ) ) ? $src[0] : null; } /** * Tabs in navigation order, flagged by who renders them. * * @return array */ private function tab_list() { $list = array(); foreach ( $this->tabs() as $slug => $tab ) { $list[] = array( 'slug' => $slug, 'label' => self::tab_label( $tab ), 'icon' => is_array( $tab ) && isset( $tab['icon'] ) ? sanitize_key( $tab['icon'] ) : '', 'core' => in_array( $slug, self::CORE_TABS, true ), ); } return $list; } /** * The label of a tab in either supported shape. * * @param string|array $tab Tab definition. * @return string */ public static function tab_label( $tab ) { if ( is_array( $tab ) ) { return isset( $tab['label'] ) ? (string) $tab['label'] : ''; } return (string) $tab; } /** * Audit check definitions, so Overview can name a failing check without * having run an audit. * * @return array */ private function check_list() { $checks = array(); foreach ( Oorank_Audit::check_definitions() as $key => $def ) { $checks[] = array( 'key' => $key, 'label' => $def['label'], 'weight' => (int) $def['weight'], 'scope' => isset( $def['scope'] ) ? $def['scope'] : 'page', 'help' => $def['help'], ); } return $checks; } /** * Roles the capability can be granted to, for the Advanced screen. * * @return array[] */ private function role_list() { $out = array(); foreach ( Oorank_Roles::assignable() as $slug => $label ) { $out[] = array( 'slug' => $slug, 'label' => $label, 'locked' => in_array( $slug, Oorank_Roles::ALWAYS, true ), ); } return $out; } /** * Per-language llms.txt endpoints, empty on a monolingual site. * * @return array[] */ private function language_endpoints() { if ( ! Oorank_Multilingual::active() ) { return array(); } $out = array(); foreach ( Oorank_Multilingual::languages() as $code => $label ) { $out[] = array( 'code' => $code, 'label' => $label, 'index' => Oorank_Multilingual::endpoint( $code, 'index' ), 'full' => Oorank_Multilingual::endpoint( $code, 'full' ), ); } return $out; } /** * User-facing strings handed to the app. * * Translation happens here rather than through a wp.i18n language pack, so * the plugin ships one .pot and no build-time i18n step. * * @return array */ private static function strings() { return array( // Shell + chrome. 'primaryNav' => __( 'Oorank sections', 'oorank' ), 'viewSite' => __( 'View site', 'oorank' ), 'siteReadiness' => __( 'Site readiness', 'oorank' ), /* translators: %s: number of pages covered by the site report. */ 'acrossPages' => __( 'across %s pages', 'oorank' ), 'unsavedChanges' => __( 'You have unsaved changes', 'oorank' ), 'unsavedRegion' => __( 'Unsaved changes', 'oorank' ), 'saveChanges' => __( 'Save changes', 'oorank' ), 'discard' => __( 'Discard', 'oorank' ), 'dismiss' => __( 'Dismiss', 'oorank' ), 'saved' => __( 'Settings saved.', 'oorank' ), 'saveFailed' => __( 'Save failed.', 'oorank' ), 'justNow' => __( 'just now', 'oorank' ), /* translators: %s: a human-readable duration, e.g. "2 hours". */ 'agoFallback' => __( '%s ago', 'oorank' ), // Overview. 'overviewTitle' => __( 'Overview', 'oorank' ), 'overviewBlurb' => __( 'How ready this site is to be read and cited by AI answer engines.', 'oorank' ), 'readinessScore' => __( 'AI readiness score', 'oorank' ), 'outOf100' => __( 'of 100', 'oorank' ), 'noScoreTitle' => __( 'No readiness score yet', 'oorank' ), 'noScoreBody' => __( 'Run a site scan and Oorank will fetch each published page the way an AI crawler does, then score what it can actually read.', 'oorank' ), 'runFirstScan' => __( 'Run the first scan', 'oorank' ), 'scanning' => __( 'Scanning…', 'oorank' ), 'topFixes' => __( 'Top fixes', 'oorank' ), 'topFixesBlurb' => __( 'The lowest-scoring pages, worst first. Fixing these moves the average fastest.', 'oorank' ), 'allClearTitle' => __( 'Nothing needs attention', 'oorank' ), 'allClearBody' => __( 'Every audited page scores well. Re-scan after publishing new content.', 'oorank' ), 'viewFullReport' => __( 'View full report', 'oorank' ), 'quickActions' => __( 'Quick actions', 'oorank' ), 'lastScanned' => __( 'Last scanned', 'oorank' ), 'pagesAudited' => __( 'Pages audited', 'oorank' ), 'neverScanned' => __( 'Never', 'oorank' ), // Pro promo (shown only while the add-on is missing or dormant). 'proEyebrow' => __( 'Oorank Pro', 'oorank' ), 'proTitle' => __( 'Unlock the full Oorank toolkit', 'oorank' ), 'proBlurb' => __( 'See which AI crawlers actually read your pages, track the answers that cite you, and get per-page fixes written for you.', 'oorank' ), 'proFeatureCrawlers' => __( 'Crawler analytics with verified AI bot hits', 'oorank' ), 'proFeatureCitations' => __( 'Citation tracking for ChatGPT, Perplexity and Gemini referrals', 'oorank' ), 'proFeatureTips' => __( 'Per-page answer tips in the editor', 'oorank' ), 'proFeatureWoo' => __( 'WooCommerce product feed and Product schema', 'oorank' ), 'proFeatureUpdates' => __( 'Licensed automatic updates and priority support', 'oorank' ), 'proUpgrade' => __( 'Upgrade now', 'oorank' ), 'proInstalledTitle' => __( 'Oorank Pro is installed but inactive', 'oorank' ), 'proInstalledBlurb' => __( 'Activate the add-on to switch on crawler analytics, citation tracking and answer tips.', 'oorank' ), 'proActivate' => __( 'Activate Oorank Pro', 'oorank' ), 'proActivating' => __( 'Activating Oorank Pro…', 'oorank' ), 'proReady' => __( 'Oorank Pro is active. Reloading…', 'oorank' ), 'proFailed' => __( 'Oorank Pro could not be set up. Please try again.', 'oorank' ), // Status cards. 'statusLlms' => __( 'llms.txt', 'oorank' ), 'statusBots' => __( 'AI bot access', 'oorank' ), 'statusRobots' => __( 'robots.txt', 'oorank' ), 'statusHeaders' => __( 'AI headers', 'oorank' ), 'notBuilt' => __( 'Not built yet', 'oorank' ), 'served' => __( 'Served', 'oorank' ), 'allAllowed' => __( 'All allowed', 'oorank' ), 'allBlocked' => __( 'All blocked', 'oorank' ), /* translators: %s: number of blocked bots. */ 'nBlocked' => __( '%s blocked', 'oorank' ), 'managed' => __( 'Managed by WordPress', 'oorank' ), 'staticFile' => __( 'Static file', 'oorank' ), 'enabled' => __( 'Enabled', 'oorank' ), 'disabled' => __( 'Off', 'oorank' ), // AI Access. 'accessBlurb' => __( 'Decide which AI crawlers may read this site, and how that decision is published.', 'oorank' ), 'crawlers' => __( 'Crawlers', 'oorank' ), 'crawlersBlurb' => __( 'Allowing an AI search crawler is what makes citation possible. Blocking a training crawler only opts out of model training — it costs no citations.', 'oorank' ), 'allowAll' => __( 'Allow all', 'oorank' ), 'blockAll' => __( 'Block all', 'oorank' ), 'preset' => __( 'Preset', 'oorank' ), 'custom' => __( 'Custom', 'oorank' ), 'allowed' => __( 'Allowed', 'oorank' ), 'blocked' => __( 'Blocked', 'oorank' ), /* translators: %s: bot name. */ 'toggleBot' => __( 'Allow %s to crawl this site', 'oorank' ), 'docs' => __( 'Docs', 'oorank' ), 'presetAllAllowed' => __( 'All allowed', 'oorank' ), 'presetAllBlocked' => __( 'All blocked', 'oorank' ), /* translators: 1: number of allowed crawlers, 2: total crawlers. */ 'presetMixed' => __( '%1$s of %2$s allowed', 'oorank' ), 'publishedVia' => __( 'Published through robots.txt, which crawlers read before fetching anything.', 'oorank' ), 'scoreBlurb' => __( 'The average across every audited page, weighted by the signals answer engines actually read.', 'oorank' ), 'categorySearchNote' => __( 'These crawlers feed answer engines that can cite you. Blocking one removes that possibility.', 'oorank' ), 'categoryTrainingNote' => __( 'These crawlers collect text to train models. Blocking them opts out of training and costs no citations.', 'oorank' ), 'categoryDatasetNote' => __( 'Open dataset crawlers. Their archives are used widely, including for training.', 'oorank' ), 'citationWarning' => __( 'AI search crawlers are blocked', 'oorank' ), /* translators: %s: comma-separated bot names. */ 'citationWarnBody' => __( 'Blocking %s removes every opportunity for those engines to cite this site. Training-only crawlers can stay blocked without that cost.', 'oorank' ), 'publishing' => __( 'Publishing', 'oorank' ), 'publishingBlurb' => __( 'How the crawler decision reaches the crawlers.', 'oorank' ), 'previewRobots' => __( 'Preview robots.txt', 'oorank' ), 'noaiLabel' => __( 'Send X-Robots-Tag: noai, noimageai', 'oorank' ), 'noaiHint' => __( 'A supplementary hint sent only to crawlers you have blocked, and only when they identify themselves. robots.txt remains the primary signal.', 'oorank' ), 'staticRobotsTitle' => __( 'A static robots.txt file is shadowing WordPress', 'oorank' ), /* translators: %s: absolute path to the static robots.txt file. */ 'staticRobotsBody' => __( 'Your web server serves %s directly, so these settings cannot reach crawlers. Write the Oorank block into that file instead — anything you have written outside the block is preserved.', 'oorank' ), 'writeRobots' => __( 'Write to robots.txt', 'oorank' ), 'robotsWritten' => __( 'Oorank block written to robots.txt.', 'oorank' ), 'robotsFailed' => __( 'Could not write robots.txt.', 'oorank' ), // llms.txt. 'llmsBlurb' => __( 'A machine-readable index of this site, served at /llms.txt for AI crawlers.', 'oorank' ), 'sources' => __( 'Sources', 'oorank' ), 'sourcesBlurb' => __( 'Which content appears in the index. The site audit covers exactly these post types.', 'oorank' ), 'generation' => __( 'Generation', 'oorank' ), 'modeAuto' => __( 'Automatic', 'oorank' ), 'modeManual' => __( 'Manual', 'oorank' ), 'modeAutoHint' => __( 'Oorank rebuilds the index from your content and keeps it in sync as you publish.', 'oorank' ), 'modeManualHint' => __( 'Your text below is served verbatim. Oorank stops syncing it with your content.', 'oorank' ), 'overrideLabel' => __( 'Index content', 'oorank' ), 'endpoints' => __( 'Endpoints', 'oorank' ), 'rebuildNow' => __( 'Rebuild now', 'oorank' ), /* translators: 1: index file size, 2: full file size. */ 'rebuilt' => __( 'Rebuilt — %1$s index, %2$s full text.', 'oorank' ), 'rebuildFailed' => __( 'Rebuild failed.', 'oorank' ), 'plainPermalinks' => __( 'Plain permalinks are active', 'oorank' ), 'plainPermalinkBody' => __( 'Virtual endpoints need pretty permalinks. Until you switch, Rebuild writes physical llms.txt and llms-full.txt files to your site root instead.', 'oorank' ), 'permalinkSettings' => __( 'Permalink settings', 'oorank' ), 'permalinkChange' => __( 'Change permalinks', 'oorank' ), 'permalinkDrawerTitle' => __( 'Permalink structure', 'oorank' ), 'permalinkDrawerBlurb' => __( 'How every URL on this site is written. Anything but Plain gives Oorank the rewrite rules its endpoints need.', 'oorank' ), 'permalinkRecommended' => __( 'Recommended', 'oorank' ), 'permalinkSave' => __( 'Save structure', 'oorank' ), 'permalinkSaved' => __( 'Permalinks updated. Reloading…', 'oorank' ), 'permalinkFailed' => __( 'The permalink structure could not be saved.', 'oorank' ), 'permalinkWpScreen' => __( 'WordPress settings', 'oorank' ), 'permalinkWpScreenHint' => __( 'For a custom structure, or the category and tag bases, open the WordPress permalink screen.', 'oorank' ), 'close' => __( 'Close', 'oorank' ), // Schema. 'schemaBlurb' => __( 'Structured data describing this site and its content, in the form answer engines parse.', 'oorank' ), 'schemaOutput' => __( 'Output', 'oorank' ), 'schemaOutputBlurb' => __( 'When Oorank publishes its JSON-LD graph.', 'oorank' ), 'schemaModeAuto' => __( 'Automatic', 'oorank' ), 'schemaModeAlways' => __( 'Always', 'oorank' ), 'schemaModeOff' => __( 'Off', 'oorank' ), 'schemaAutoHint' => __( 'Oorank emits only the types nothing else on the site publishes. Two competing descriptions of the same page are worse than one.', 'oorank' ), 'schemaAlwaysHint' => __( 'Oorank emits its full graph regardless of what other plugins publish. Use this only if you know the duplicate types are wanted.', 'oorank' ), 'schemaOffHint' => __( 'Oorank publishes no structured data. Your audit score will reflect whatever another plugin emits.', 'oorank' ), 'schemaEmitterTitle' => __( 'Another plugin is publishing structured data', 'oorank' ), /* translators: %s: comma-separated plugin names. */ 'schemaEmitterBody' => __( '%s already describes pages in JSON-LD. In Automatic mode Oorank stays out of its way and adds only the types it does not cover, such as FAQ and How-to.', 'oorank' ), 'schemaIdentity' => __( 'Publisher identity', 'oorank' ), 'schemaIdentityBlurb' => __( 'Who this site is, in the terms an answer engine uses to attribute a citation.', 'oorank' ), 'schemaEntityType' => __( 'Published by', 'oorank' ), 'schemaOrganization' => __( 'An organization', 'oorank' ), 'schemaPerson' => __( 'A person', 'oorank' ), 'schemaEntityName' => __( 'Name', 'oorank' ), /* translators: %s: the site title, used as the fallback name. */ 'schemaEntityNameHint' => __( 'Leave empty to use the site title, %s.', 'oorank' ), 'schemaLogo' => __( 'Logo', 'oorank' ), 'schemaLogoHint' => __( 'A square image of at least 112×112 pixels. Falls back to your theme logo or site icon.', 'oorank' ), 'schemaChooseLogo' => __( 'Choose logo', 'oorank' ), 'schemaReplaceLogo' => __( 'Replace', 'oorank' ), 'schemaRemoveLogo' => __( 'Remove', 'oorank' ), 'schemaLogoFallback' => __( 'Using your theme logo or site icon.', 'oorank' ), 'schemaLogoSet' => __( 'Custom logo.', 'oorank' ), 'schemaLogoNone' => __( 'No logo set.', 'oorank' ), 'schemaLogoMissing' => __( 'The chosen image is no longer in the media library.', 'oorank' ), 'schemaMediaTitle' => __( 'Select a logo', 'oorank' ), 'schemaMediaButton' => __( 'Use this image', 'oorank' ), 'schemaSameAs' => __( 'Profiles elsewhere', 'oorank' ), 'schemaSameAsHint' => __( 'One URL per line — the profiles that identify this publisher on other sites. Answer engines use them to resolve a name into an entity they already know.', 'oorank' ), 'schemaAuthors' => __( 'Author authority', 'oorank' ), 'schemaAuthorsBlurb' => __( 'Every post is attributed to its author as an entity, not a name.', 'oorank' ), 'schemaAuthorsBody' => __( 'Oorank adds profile URL fields to each user\'s WordPress profile — LinkedIn, X, Mastodon, GitHub, YouTube, ORCID. Whatever an author fills in becomes their sameAs, which is how an engine tells one byline from another with the same name.', 'oorank' ), 'schemaEditProfiles' => __( 'Edit user profiles', 'oorank' ), /* translators: %s: number of authors with at least one profile URL. */ 'schemaAuthorsLinked' => __( '%s with profile URLs', 'oorank' ), /* translators: %s: total number of publishing authors. */ 'schemaAuthorsTotal' => __( 'of %s publishing authors', 'oorank' ), 'schemaTypes' => __( 'What Oorank describes', 'oorank' ), 'schemaTypesBlurb' => __( 'The node types in the graph, and where each one comes from.', 'oorank' ), 'schemaTypeWebSite' => __( 'WebSite and publisher — every page', 'oorank' ), 'schemaTypeWebPage' => __( 'WebPage — every published post and page', 'oorank' ), 'schemaTypeArticle' => __( 'Article — posts and custom post types', 'oorank' ), 'schemaTypeFaq' => __( 'FAQPage — posts with two or more Details blocks', 'oorank' ), 'schemaTypeHowTo' => __( 'HowTo — posts with a how-to block', 'oorank' ), 'schemaSuppressed' => __( 'Published elsewhere', 'oorank' ), 'schemaActive' => __( 'Published by Oorank', 'oorank' ), // Audit. 'auditBlurb' => __( 'Oorank fetches your pages the way an AI crawler does, then scores what it can actually read.', 'oorank' ), 'auditUrl' => __( 'Audit a URL', 'oorank' ), 'auditUrlBlurb' => __( 'Any address on this site. A full URL or just a path.', 'oorank' ), 'runAudit' => __( 'Run audit', 'oorank' ), 'fetching' => __( 'Fetching the page as a bot would…', 'oorank' ), 'auditFailed' => __( 'Audit failed.', 'oorank' ), 'notAudited' => __( 'Not audited yet. Run one to see how an AI crawler reads this page.', 'oorank' ), 'siteReport' => __( 'Site report', 'oorank' ), 'scanSite' => __( 'Scan site', 'oorank' ), 'rescan' => __( 'Re-scan', 'oorank' ), 'scanStarting' => __( 'Starting scan…', 'oorank' ), /* translators: 1: pages audited so far, 2: total pages queued. */ 'scanProgress' => __( 'Audited %1$s of %2$s…', 'oorank' ), /* translators: %s: total number of pages audited. */ 'scanComplete' => __( 'Scan complete — %s pages audited.', 'oorank' ), 'scanFailed' => __( 'Scan failed.', 'oorank' ), 'searchPages' => __( 'Search pages', 'oorank' ), 'searchPlaceholder' => __( 'Search by title or URL…', 'oorank' ), 'filterAll' => __( 'All', 'oorank' ), 'filterNeedsWork' => __( 'Needs work', 'oorank' ), 'filterGood' => __( 'Good', 'oorank' ), 'colScore' => __( 'Score', 'oorank' ), 'colContent' => __( 'Content', 'oorank' ), 'colAudited' => __( 'Audited', 'oorank' ), 'edit' => __( 'Edit', 'oorank' ), 'details' => __( 'Details', 'oorank' ), 'hideDetails' => __( 'Hide details', 'oorank' ), 'noResultsTitle' => __( 'No pages match', 'oorank' ), 'noResultsBody' => __( 'Try a different search term, or clear the filter to see every audited page.', 'oorank' ), 'clearFilters' => __( 'Clear filters', 'oorank' ), 'noReportTitle' => __( 'No site report yet', 'oorank' ), 'noReportBody' => __( 'A scan audits every published page in the post types your llms.txt advertises. A background scan also refreshes this weekly.', 'oorank' ), /* translators: %s: number of pages that will be scanned. */ 'willScan' => __( 'Covers %s published items.', 'oorank' ), 'fixThis' => __( 'Fix this', 'oorank' ), /* translators: %s: number of points the check is worth. */ 'points' => __( '%s pts', 'oorank' ), 'checksPassing' => __( 'passing', 'oorank' ), 'whatWeChecked' => __( 'What Oorank checked', 'oorank' ), // Freshness. 'freshness' => __( 'Content freshness', 'oorank' ), 'freshnessBlurb' => __( 'Answer engines discount content that has not changed in a long time. Oorank publishes the date your content actually changed, not the date WordPress was last touched.', 'oorank' ), 'freshnessThreshold' => __( 'Consider content stale after', 'oorank' ), /* translators: %s: number of months. */ 'freshnessMonths' => __( '%s months', 'oorank' ), 'freshnessLoading' => __( 'Checking…', 'oorank' ), 'freshnessFailed' => __( 'Could not check content freshness.', 'oorank' ), 'freshnessAllFresh' => __( 'Nothing has gone stale', 'oorank' ), 'freshnessFreshBody' => __( 'Every published item has changed inside your threshold.', 'oorank' ), /* translators: %s: the number 1, as a formatted string. */ 'freshnessStaleOne' => __( '%s item needs a refresh', 'oorank' ), /* translators: %s: number of stale items, two or more. */ 'freshnessStaleMany' => __( '%s items need a refresh', 'oorank' ), 'freshnessCapped' => __( 'Showing the oldest of at least this many.', 'oorank' ), /* translators: %s: a relative time, e.g. "2 years ago". */ 'freshnessAge' => __( 'Content last changed %s', 'oorank' ), 'freshnessApplyHint' => __( 'Save to apply this threshold to your audit score.', 'oorank' ), 'showPassing' => __( 'Show passing checks', 'oorank' ), 'hidePassing' => __( 'Hide passing checks', 'oorank' ), /* translators: 1: number of rows shown, 2: total rows. */ 'showingOf' => __( '%1$s of %2$s', 'oorank' ), /* translators: %s: number of additional rows revealed. */ 'showMore' => __( 'Show %s more', 'oorank' ), 'noSourcesHint' => __( 'With no post types selected the index is empty and the site audit has nothing to scan.', 'oorank' ), // Connect. 'toolsBlurb' => __( 'Give an AI client you already use a way to read this site and change its settings.', 'oorank' ), 'copy' => __( 'Copy', 'oorank' ), 'mcpTitle' => __( 'This site as an MCP server', 'oorank' ), 'mcpBlurb' => __( 'A client you already use can read this site’s audit results and change its crawler and llms.txt settings. The client connects to your site; your site never calls out.', 'oorank' ), 'mcpEnable' => __( 'Enable MCP access', 'oorank' ), 'mcpEnableHint' => __( 'While this is off the endpoint does not exist at all — it answers 404, not "forbidden".', 'oorank' ), 'mcpUnsaved' => __( 'The toggle is staged but not saved yet. The endpoint stays closed until you save.', 'oorank' ), 'mcpReady' => __( 'Ready', 'oorank' ), 'mcpNotReady' => __( 'Not connectable', 'oorank' ), 'mcpEndpoint' => __( 'Connection URL', 'oorank' ), 'mcpEndpointHint' => __( 'The same URL for every client below.', 'oorank' ), 'mcpEndpointPlain' => __( 'This site uses plain permalinks, so the REST route is the connection URL.', 'oorank' ), 'mcpToken' => __( 'Connection token', 'oorank' ), 'mcpTokenOnce' => __( 'Shown once, and already filled into every snippet below. Oorank keeps only a hash.', 'oorank' ), 'mcpTokenHidden' => __( 'The snippets below carry a placeholder where the token goes. Rotate the token above to paste a real one in.', 'oorank' ), /* translators: %s: display name of the user the connection acts as. */ 'mcpTokenActive' => __( 'A token is active, acting as %s. Rotate to see a new one.', 'oorank' ), 'mcpTokenNoUser' => __( 'an unknown user', 'oorank' ), 'mcpTokenNone' => __( 'No token issued yet, so nothing can connect.', 'oorank' ), 'mcpIssue' => __( 'Issue token', 'oorank' ), 'mcpRotate' => __( 'Rotate', 'oorank' ), 'mcpRevoke' => __( 'Revoke', 'oorank' ), 'mcpTokenIssued' => __( 'Connection token issued. Copy it now.', 'oorank' ), 'mcpTokenRevoked' => __( 'Connection token revoked.', 'oorank' ), 'mcpTokenFailed' => __( 'Could not issue a token.', 'oorank' ), 'mcpStatusFailed' => __( 'Could not read the MCP status.', 'oorank' ), 'mcpAbilities' => __( 'What a connected client can do', 'oorank' ), 'mcpAbilitiesBlurb' => __( 'Generated from the tools the server actually offers, filtered to what your account may use.', 'oorank' ), 'clientGroups' => __( 'Client types', 'oorank' ), 'clientsAssistants' => __( 'Assistants', 'oorank' ), 'clientsEditors' => __( 'Editors & CLI', 'oorank' ), 'clientBlocked' => __( 'Needs OAuth', 'oorank' ), 'clientTerminal' => __( 'Terminal', 'oorank' ), 'clientClaudeBlurb' => __( 'Desktop app. Claude reaches this site through the standard remote bridge, which carries the token for it.', 'oorank' ), 'clientClaudeConfig' => __( 'claude_desktop_config.json', 'oorank' ), 'clientClaudeStep1' => __( 'Claude → Settings → Developer → Edit config.', 'oorank' ), 'clientClaudeStep2' => __( 'Paste the block above into the file and save.', 'oorank' ), 'clientClaudeStep3' => __( 'Restart Claude. Oorank appears in the tools menu.', 'oorank' ), 'clientChatgptBlurb' => __( 'ChatGPT connectors sign in with OAuth and cannot be given a token, so this endpoint is not one it can add yet.', 'oorank' ), 'clientChatgptNote' => __( 'Anything that runs on your own machine — Claude, an editor, the CLI — connects today.', 'oorank' ), 'clientClaudeCodeBlurb' => __( 'One command, run in your project.', 'oorank' ), 'clientCursorBlurb' => __( 'Add to ~/.cursor/mcp.json, or .cursor/mcp.json in a project.', 'oorank' ), 'clientVscodeBlurb' => __( 'Copilot chat reads .vscode/mcp.json in the workspace.', 'oorank' ), 'clientGeneric' => __( 'Any other client', 'oorank' ), 'clientGenericBlurb' => __( 'Clients that speak MCP over stdio only can reach this endpoint through the same bridge.', 'oorank' ), 'clientGenericConfig' => __( 'MCP client config', 'oorank' ), 'auditSettings' => __( 'Audit settings', 'oorank' ), 'answerReadyClean' => __( 'Nothing to rewrite: headings, paragraph length and openers all read as liftable.', 'oorank' ), 'guidanceHeadings' => __( 'Headings that are not questions', 'oorank' ), 'guidanceHeadingsHint' => __( 'Rewrite these as the question a reader would type.', 'oorank' ), 'guidanceParagraphs' => __( 'Paragraphs too long to lift', 'oorank' ), 'guidanceParagraphsHint' => __( 'Split each into passages that answer one thing.', 'oorank' ), 'guidanceSentences' => __( 'Sentences too long to quote', 'oorank' ), 'guidanceSentencesHint' => __( 'Break these in two.', 'oorank' ), 'guidanceOpeners' => __( 'Paragraphs opening with a pronoun', 'oorank' ), 'guidanceOpenersHint' => __( 'Name the subject, so the paragraph still means something when quoted alone.', 'oorank' ), /* translators: %s: number of words. */ 'wordsCount' => __( '%s words', 'oorank' ), /* translators: 1: page-scope score, 2: site-scope score. */ 'scoreSplit' => __( 'page %1$s · site %2$s', 'oorank' ), /* translators: %s: site-wide score out of 100. */ 'siteChecksNote' => __( 'Scores here are per-page; the site-wide checks score %s and are the same on every row.', 'oorank' ), // Advanced. 'advancedBlurb' => __( 'Diagnostics and environment details, for when something is not behaving.', 'oorank' ), 'diagVersion' => __( 'Oorank version', 'oorank' ), 'diagSite' => __( 'Site', 'oorank' ), 'diagPermalinks' => __( 'Pretty permalinks', 'oorank' ), 'diagStaticRobots' => __( 'Static robots.txt', 'oorank' ), 'diagRewrites' => __( 'Virtual endpoints', 'oorank' ), 'diagIndexSize' => __( 'llms.txt size', 'oorank' ), 'diagFullSize' => __( 'llms-full.txt size', 'oorank' ), 'diagScannable' => __( 'Scannable items', 'oorank' ), 'yes' => __( 'Yes', 'oorank' ), 'no' => __( 'No', 'oorank' ), 'aboutTitle' => __( 'About Oorank', 'oorank' ), 'aboutBody' => __( 'Oorank makes a site readable and quotable by AI answer engines. Everything runs on your own server: no content leaves the site, and no external service is contacted.', 'oorank' ), 'diagnostics' => __( 'Diagnostics', 'oorank' ), 'diagnosticsBlurb' => __( 'What Oorank can see about this install. Include this when reporting a problem.', 'oorank' ), 'copyDiagnostics' => __( 'Copy diagnostics', 'oorank' ), 'copied' => __( 'Copied to clipboard.', 'oorank' ), 'copyFailed' => __( 'Could not copy. Select the text and copy manually.', 'oorank' ), // Grades and statuses. 'gradeExcellent' => _x( 'Excellent', 'audit grade', 'oorank' ), 'gradeGood' => _x( 'Good', 'audit grade', 'oorank' ), 'gradeFair' => _x( 'Needs work', 'audit grade', 'oorank' ), 'gradePoor' => _x( 'Poor', 'audit grade', 'oorank' ), 'statusPass' => _x( 'Pass', 'audit check status', 'oorank' ), 'statusWarn' => _x( 'Warning', 'audit check status', 'oorank' ), 'statusFail' => _x( 'Fail', 'audit check status', 'oorank' ), // Bot categories. 'categoryTraining' => __( 'Model training', 'oorank' ), 'categorySearch' => __( 'AI search', 'oorank' ), 'categoryDataset' => __( 'Dataset', 'oorank' ), ); } /** * Render the app root. * * Extension tabs are rendered into a hidden node the app relocates, so * `oorank_render_tab` keeps working exactly as documented. Two placement * constraints decide where that node goes, and they point the same way: * mounting a React root replaces every child of its container, so the node * cannot live inside `#oorank-app`; and wp-admin relocates notices to the * top of `.wrap`, so it cannot be `.wrap`'s first child either, or a * third-party notice lands inside the add-on's panel. It therefore trails * the app root. */ public function render() { if ( ! current_user_can( 'manage_options' ) ) { return; } $active = $this->active_tab(); echo '
'; /* * wp-admin hoists every admin notice to just before `.wp-header-end`, * falling back to "after the first heading in .wrap" when no marker is * present. Without this pair a third-party notice lands inside * whichever panel happens to own the first heading — for an add-on tab, * inside its card. The heading is for screen readers only; the app * paints its own visible title. */ echo '

' . esc_html__( 'Oorank', 'oorank' ) . '

'; echo '
'; echo '
'; echo ''; echo '
'; if ( ! in_array( $active, self::CORE_TABS, true ) ) { echo ''; } echo '
'; } }