template name, in menu order. * * @var array */ public const PAGES = [ 'acid-aeo' => 'dashboard', 'acid-aeo-wizard' => 'wizard', 'acid-aeo-settings' => 'settings', 'acid-aeo-citations' => 'citations', 'acid-aeo-tools' => 'tools', ]; /** * Transient the activator sets to ask for the wizard once. * * @var string */ public const REDIRECT_TRANSIENT = 'acid_aeo_activation_redirect'; /** * User meta remembering that the setup notice was dismissed. * * @var string */ public const NOTICE_META = 'acid_aeo_notice_dismissed'; /** * `admin-post.php` action that stores the dismissal. * * @var string */ public const DISMISS_ACTION = 'acid_aeo_dismiss_notice'; /** * ID of the "AI readiness" dashboard widget. * * @var string */ public const WIDGET_ID = 'acid_aeo_widget'; /** * Menu icon: a rounded square with the mark knocked out of it. * * Kept in sync with `assets/admin/icon.svg`, which is the drawing source. * Monochrome `#a7aaad` so WordPress recolours it with the admin scheme. * * @var string */ private const ICON_SVG = ''; /** * Hooks the admin shell into WordPress. * * @return void */ public function register(): void { add_action( 'admin_menu', [ $this, 'menu' ] ); add_action( 'admin_init', [ $this, 'maybe_redirect' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue' ] ); add_filter( 'admin_body_class', [ $this, 'body_class' ] ); add_action( 'admin_notices', [ $this, 'notice' ] ); add_action( 'admin_post_' . self::DISMISS_ACTION, [ $this, 'dismiss_notice' ] ); add_action( 'wp_dashboard_setup', [ $this, 'dashboard_widget' ] ); add_filter( 'plugin_action_links_' . plugin_basename( ACID_AEO_FILE ), [ $this, 'action_links' ] ); add_filter( 'plugin_row_meta', [ $this, 'row_meta' ], 10, 2 ); } /** * Registers the top level menu and its five screens. * * @return void */ public function menu(): void { add_menu_page( __( 'Acid AEO', 'acid-aeo' ), __( 'Acid AEO', 'acid-aeo' ), self::CAPABILITY, self::MENU_SLUG, [ $this, 'render_dashboard' ], self::icon_uri(), 81 ); $submenus = [ [ 'acid-aeo', __( 'Dashboard', 'acid-aeo' ), 'render_dashboard' ], [ 'acid-aeo-wizard', __( 'Setup Wizard', 'acid-aeo' ), 'render_wizard' ], [ 'acid-aeo-settings', __( 'Settings', 'acid-aeo' ), 'render_settings' ], [ 'acid-aeo-citations', __( 'AI Citations', 'acid-aeo' ), 'render_citations' ], [ 'acid-aeo-tools', __( 'Tools', 'acid-aeo' ), 'render_tools' ], ]; foreach ( $submenus as $submenu ) { add_submenu_page( self::MENU_SLUG, $submenu[1], $submenu[1], self::CAPABILITY, $submenu[0], [ $this, $submenu[2] ] ); } } /** * Sends a freshly activated site to the wizard, once. * * Skipped on the network screens and on a bulk activation, where a redirect * would interrupt somebody activating twenty plugins at a time. The * transient is deleted either way, so this happens at most once. * * @return void */ public function maybe_redirect(): void { if ( ! get_transient( self::REDIRECT_TRANSIENT ) ) { return; } delete_transient( self::REDIRECT_TRANSIENT ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the flag core adds when several plugins were activated at once; nothing is written. $bulk = isset( $_GET['activate-multi'] ); if ( $bulk || is_network_admin() || wp_doing_ajax() || ! current_user_can( self::CAPABILITY ) ) { return; } if ( (bool) Options::get( 'general.wizard_completed', false ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=acid-aeo-wizard' ) ); exit; } /** * Loads the admin stylesheet and script on the plugin's own screens, and * the widget's small stylesheet on WordPress's dashboard. * * @param string $hook_suffix Screen hook suffix handed in by WordPress. * @return void */ public function enqueue( $hook_suffix ): void { // The widget lives on WordPress's dashboard, where none of the plugin's // own sheets load: it gets a small one, and no fonts or scripts. if ( 'index.php' === $hook_suffix ) { if ( current_user_can( self::CAPABILITY ) ) { wp_enqueue_style( 'acid-aeo-widget', ACID_AEO_URL . 'assets/admin/widget.css', [], ACID_AEO_VERSION ); } return; } $page = self::current_page( is_string( $hook_suffix ) ? $hook_suffix : '' ); if ( '' === $page ) { return; } wp_enqueue_style( 'acid-aeo-admin', ACID_AEO_URL . 'assets/admin/admin.css', [], ACID_AEO_VERSION ); wp_enqueue_script( 'acid-aeo-admin', ACID_AEO_URL . 'assets/admin/admin.js', [ 'wp-api-fetch', 'wp-i18n', 'wp-url' ], ACID_AEO_VERSION, true ); wp_set_script_translations( 'acid-aeo-admin', 'acid-aeo', ACID_AEO_DIR . 'languages' ); if ( 'settings' === $page ) { wp_enqueue_media(); } wp_add_inline_script( 'acid-aeo-admin', 'window.acidAeo = ' . (string) wp_json_encode( self::payload( $page ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' ); if ( 'wizard' === $page ) { Wizard::enqueue(); } } /** * Adds the wizard's body class so the stylesheet can take the screen over. * * The wizard is the one screen that is not a WordPress form: it runs full * width, without the admin menu, because a seven step setup with a live * preview beside it has nowhere to put a 160 px sidebar. The admin bar * stays — leaving somebody with no way out of a page is worse than a * cramped layout. * * @param mixed $classes Space separated class list handed in by WordPress. * @return string Class list, with the wizard class added on that screen. */ public function body_class( $classes ): string { $classes = is_string( $classes ) ? $classes : ''; if ( 'wizard' !== self::current_page() ) { return $classes; } return trim( $classes . ' ' . Wizard::BODY_CLASS ); } /** * Everything `admin.js` is handed on the way in. * * Public so a screen added later can reuse the same shape, and so the keys * are readable in one place rather than spread through the script. * * @param string $page Page key, e.g. `dashboard`. * @return array Payload assigned to `window.acidAeo`. */ public static function payload( string $page ): array { return [ 'restUrl' => rest_url( Controller::REST_NAMESPACE . '/' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'page' => $page, 'settings' => Options::all(), 'defaults' => Options::defaults(), 'environment' => Environment::detect(), 'audit' => 'dashboard' === $page ? Runner::run( false ) : self::cached_audit(), 'fileUrls' => SiteData::file_urls(), 'files' => Router::files(), 'staticFiles' => StaticFiles::choices(), 'version' => ACID_AEO_VERSION, 'adminUrl' => admin_url( 'admin.php' ), 'modules' => self::module_titles(), 'engines' => self::engine_labels(), 'bands' => self::band_labels(), 'i18n' => self::strings(), ]; } /** * The last audit result, without computing a new one. * * @return array|null Cached result, or null when there is none. */ public static function cached_audit(): ?array { $cached = Cache::get( Runner::CACHE_KEY ); return is_array( $cached ) ? $cached : null; } /** * The page key of the screen being rendered, or an empty string elsewhere. * * The requested `page` decides it, because the screen id is built from the * *translated* menu title: on a Romanian site the settings screen answers * to `acid-aeo_page_acid-aeo-settings` only by luck, and a menu title with * a diacritic in it would leave the plugin's own screens without their * stylesheet. The screen id is kept as the fallback for a screen reached * without the query argument. * * @param string $hook_suffix Screen hook suffix, used before `get_current_screen()` exists. * @return string Page key, e.g. `settings`. */ public static function current_page( string $hook_suffix = '' ): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading which admin screen is being rendered; nothing is written. $requested = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( isset( self::PAGES[ $requested ] ) ) { return self::PAGES[ $requested ]; } $screen_id = $hook_suffix; if ( function_exists( 'get_current_screen' ) ) { $screen = get_current_screen(); if ( null !== $screen && '' !== (string) $screen->id ) { $screen_id = (string) $screen->id; } } $slug = ''; if ( 0 === strpos( $screen_id, 'toplevel_page_' ) ) { $slug = substr( $screen_id, strlen( 'toplevel_page_' ) ); } elseif ( 0 === strpos( $screen_id, 'acid-aeo_page_' ) ) { $slug = substr( $screen_id, strlen( 'acid-aeo_page_' ) ); } return self::PAGES[ $slug ] ?? ''; } /** * Renders the dashboard screen. * * @return void */ public function render_dashboard(): void { $this->guard(); Pages::render( 'dashboard' ); } /** * Renders the setup wizard screen. * * @return void */ public function render_wizard(): void { $this->guard(); Wizard::render(); } /** * Renders the settings screen. * * @return void */ public function render_settings(): void { $this->guard(); Pages::render( 'settings' ); } /** * Renders the AI citations screen. * * @return void */ public function render_citations(): void { $this->guard(); Pages::render( 'citations' ); } /** * Renders the tools screen. * * @return void */ public function render_tools(): void { $this->guard(); Pages::render( 'tools' ); } /** * Asks an administrator who has not finished setup to finish it. * * Only outside the plugin's own screens, where the call to action would be * noise, and only until it is dismissed. * * @return void */ public function notice(): void { if ( '' !== self::current_page() || ! current_user_can( self::CAPABILITY ) ) { return; } if ( (bool) Options::get( 'general.wizard_completed', false ) ) { return; } if ( '' !== (string) get_user_meta( get_current_user_id(), self::NOTICE_META, true ) ) { return; } $dismiss = wp_nonce_url( admin_url( 'admin-post.php?action=' . self::DISMISS_ACTION ), self::DISMISS_ACTION ); ?> 403 ] ); } check_admin_referer( self::DISMISS_ACTION ); update_user_meta( get_current_user_id(), self::NOTICE_META, '1' ); $referer = wp_get_referer(); wp_safe_redirect( false !== $referer ? $referer : admin_url() ); exit; } /** * Adds the "AI readiness" widget to the WordPress dashboard. * * @return void */ public function dashboard_widget(): void { if ( ! current_user_can( self::CAPABILITY ) ) { return; } wp_add_dashboard_widget( self::WIDGET_ID, __( 'AI readiness', 'acid-aeo' ), [ $this, 'render_widget' ] ); } /** * What the widget shows, from the cached audit or the persisted summary. * * @param array|null $cached The audit still in the transient, if any. * @param array|null $last The persisted summary, if any. * @return array{score:?int,band:string,label:string,measured:string} Widget values. */ public static function widget_summary( ?array $cached, ?array $last ): array { $audit = $cached ?? $last; if ( null === $audit || ! isset( $audit['score'] ) ) { return [ 'score' => null, 'band' => '', 'label' => self::band_label( '' ), 'measured' => '', ]; } $stamp = strtotime( (string) ( $audit['computed_at'] ?? '' ) ); $measured = ''; if ( false !== $stamp ) { $measured = sprintf( /* translators: %s: human readable time difference, e.g. "2 hours". */ __( 'Measured %s ago', 'acid-aeo' ), human_time_diff( $stamp, time() ) ); } return [ 'score' => (int) $audit['score'], 'band' => (string) ( $audit['band'] ?? '' ), 'label' => self::band_label( (string) ( $audit['band'] ?? '' ) ), 'measured' => $measured, ]; } /** * Renders the dashboard widget. * * Reads the cached audit and never runs one. The checks fetch the site * over HTTP — a dozen loopback requests — and the WordPress dashboard is * drawn on every visit to wp-admin's front page, by every editor, whether * or not anybody there is thinking about AI readiness. So with no cached * result the widget shows a placeholder and a link to the screen where * running the audit is the thing somebody asked for. * * @return void */ public function render_widget(): void { $summary = self::widget_summary( Runner::cached(), Runner::last() ); $stats = self::citation_stats( 7 ); $referrals = (int) ( $stats['totals']['referral'] ?? 0 ); $dashboard = admin_url( 'admin.php?page=acid-aeo' ); echo '
'; printf( '

%2$s %3$s

', esc_attr( '' !== $summary['band'] ? $summary['band'] : 'none' ), esc_html( null !== $summary['score'] ? (string) $summary['score'] : '—' ), esc_html( $summary['label'] ) ); if ( '' !== $summary['measured'] ) { printf( '

%s

', esc_html( $summary['measured'] ) ); } printf( '

%s

', esc_html( sprintf( /* translators: %s: number of visits. */ _n( '%s visit from an AI answer in the last 7 days.', '%s visits from AI answers in the last 7 days.', $referrals, 'acid-aeo' ), number_format_i18n( $referrals ) ) ) ); // Both links go to the same screen, so only one of them is drawn: the // one that says what is worth doing there. With no score that is // running the audit; with one it is reading the rest of the dashboard. printf( '

%2$s

', esc_url( $dashboard ), null === $summary['score'] ? esc_html__( 'Run audit', 'acid-aeo' ) : esc_html__( 'Open the Acid AEO dashboard', 'acid-aeo' ) ); echo '
'; } /** * Adds the Settings and Setup links to the plugins list row. * * @param mixed $links Action links. * @return array Action links. */ public function action_links( $links ): array { $links = is_array( $links ) ? $links : []; $mine = [ sprintf( '%2$s', esc_url( admin_url( 'admin.php?page=acid-aeo-settings' ) ), esc_html__( 'Settings', 'acid-aeo' ) ), sprintf( '%2$s', esc_url( admin_url( 'admin.php?page=acid-aeo-wizard' ) ), esc_html__( 'Setup', 'acid-aeo' ) ), ]; return array_merge( $mine, $links ); } /** * Adds a documentation link under the plugin row. * * @param mixed $links Row meta links. * @param mixed $plugin_file Plugin file the row belongs to. * @return array Row meta links. */ public function row_meta( $links, $plugin_file ): array { $links = is_array( $links ) ? $links : []; if ( plugin_basename( ACID_AEO_FILE ) !== $plugin_file ) { return $links; } $links[] = sprintf( '%2$s', esc_url( 'https://acidstudios.ro/acid-aeo/docs/' ), esc_html__( 'Documentation', 'acid-aeo' ) ); return $links; } /** * The citation statistics for a window, or empty totals when unavailable. * * @param int $days Window length in days. * @return array Statistics in the shape `Store::stats()` returns. */ public static function citation_stats( int $days ): array { $empty = [ 'totals' => [ 'crawl' => 0, 'referral' => 0, ], 'by_agent' => [], 'top_pages' => [], 'timeline' => [], ]; if ( ! class_exists( Store::class ) || ! (bool) Options::get( 'citations.enabled', true ) ) { return $empty; } $stats = Store::stats( $days ); return is_array( $stats ) ? array_merge( $empty, $stats ) : $empty; } /** * The word that goes with an audit band. * * @param string $band Band key, e.g. `good`. * @return string Translated label. */ public static function band_label( string $band ): string { $labels = self::band_labels(); return $labels[ $band ] ?? __( 'Not measured', 'acid-aeo' ); } /** * The four audit bands, translated. * * @return array Band key => translated label. */ public static function band_labels(): array { return [ 'excellent' => __( 'Excellent', 'acid-aeo' ), 'good' => __( 'Good', 'acid-aeo' ), 'fair' => __( 'Fair', 'acid-aeo' ), 'poor' => __( 'Needs work', 'acid-aeo' ), ]; } /** * Booted modules as slug => title. * * @return array Module slug => human readable title. */ public static function module_titles(): array { $titles = []; foreach ( Plugin::instance()->modules() as $slug => $module ) { if ( $module instanceof Module ) { $titles[ (string) $slug ] = $module->title(); } } return $titles; } /** * Known answer engines as slug => label. * * @return array Engine slug => label. */ public static function engine_labels(): array { $labels = []; foreach ( Engines::all() as $slug => $engine ) { $labels[ (string) $slug ] = (string) $engine['label']; } return $labels; } /** * The menu icon as a data URI. * * @return string `data:image/svg+xml;base64,…`. */ private static function icon_uri(): string { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Building the data URI WordPress expects for a menu icon, not obfuscating anything. return 'data:image/svg+xml;base64,' . base64_encode( self::ICON_SVG ); } /** * Stops a screen render the current user is not allowed to see. * * `add_menu_page()` already gates the menu entry; this gates the callback, * which is reachable on its own. * * @return void */ private function guard(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to view this page.', 'acid-aeo' ), '', [ 'response' => 403 ] ); } } /** * The strings `admin.js` needs that carry server side context. * * Everything else in the script is translated with `wp.i18n.__()`. * * @return array Key => translated string. */ private static function strings(): array { return [ 'saved' => __( 'Settings saved.', 'acid-aeo' ), 'saveFailed' => __( 'The settings could not be saved.', 'acid-aeo' ), 'flushNeeded' => __( 'Rewrite rules will refresh on the next request.', 'acid-aeo' ), 'requestError' => __( 'Something went wrong. Please try again.', 'acid-aeo' ), 'loading' => __( 'Loading…', 'acid-aeo' ), 'empty' => __( 'Nothing recorded yet.', 'acid-aeo' ), 'crawl' => __( 'AI crawl', 'acid-aeo' ), 'referral' => __( 'AI referral', 'acid-aeo' ), 'pass' => __( 'Pass', 'acid-aeo' ), 'warn' => __( 'Warning', 'acid-aeo' ), 'fail' => __( 'Fail', 'acid-aeo' ), 'skip' => __( 'Skipped', 'acid-aeo' ), 'fix' => __( 'Fix', 'acid-aeo' ), 'howToFix' => __( 'How to fix', 'acid-aeo' ), 'allGood' => __( 'Everything the audit looks at is in order.', 'acid-aeo' ), ]; } }