' ); add_menu_page( __( 'Airygen SEO', 'airygen-seo' ), __( 'Airygen SEO', 'airygen-seo' ), 'manage_options', $parent_slug, array( __CLASS__, 'render_options_page' ), $menu_icon, 58 ); foreach ( AdminPageRegistry::all() as $page ) { add_submenu_page( $parent_slug, $page['title'], $page['title'], $page['capability'], 'dashboard' === $page['key'] ? $parent_slug : $page['slug'], array( __CLASS__, 'render_options_page' ) ); } } /** * Render the options page for the plugin. * * @return void */ public static function render_options_page(): void { TemplateRenderer::render( 'admin/options', array( 'setting_group' => Constants::SETTING_GROUP, 'page' => 'airygen-options', ) ); } /** * Append a Settings link to the plugin row. * * @param array $links Existing plugin action links. * * @return array */ public static function add_settings_link( array $links ): array { $links[] = sprintf( '%s', esc_url( admin_url( 'admin.php?page=airygen-settings' ) ), esc_html__( 'Settings', 'airygen-seo' ) ); return $links; } /** * Enqueue SPA container assets when viewing the Airygen SEO settings page. * * @param string $hook Current admin screen hook. * * @return void */ public static function enqueue_assets( string $hook ): void { $current_page = self::resolve_current_page( $hook ); if ( null === $current_page ) { return; } $script_path = self::plugin_path( 'build/admin/airygen-app.js' ); if ( ! file_exists( $script_path ) ) { return; } $asset = self::asset_meta( 'build/admin/airygen-app.asset.php' ); $dependencies = self::script_dependencies( $asset ); self::enqueue_styles(); self::bootstrap_js_runtime(); wp_enqueue_script( 'airygen-admin-app', self::plugin_url( 'build/admin/airygen-app.js' ), $dependencies, $asset['version'], true ); if ( function_exists( 'wp_set_script_translations' ) ) { wp_set_script_translations( 'airygen-admin-app', 'airygen-seo', plugin_dir_path( AIRYGEN_PLUGIN_FILE ) . 'languages' ); } $yoast_active = self::is_yoast_active(); $rank_math_active = self::is_rank_math_active(); $aioseo_active = self::is_aioseo_active(); $seopress_active = self::is_seopress_active(); $default_locale = function_exists( 'get_user_locale' ) ? get_user_locale() : ''; if ( '' === $default_locale ) { $default_locale = function_exists( 'get_locale' ) ? get_locale() : 'en_US'; } $config = array( 'restPath' => '/airygen/v1/settings', 'sessionCheckUrl' => esc_url_raw( rest_url( 'airygen/v1/session-check' ) ), 'restRoot' => esc_url_raw( rest_url() ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'adminUrl' => admin_url(), 'logoutUrl' => wp_logout_url( admin_url( 'admin.php?page=airygen-dashboard' ) ), 'locale' => $default_locale, 'initialPage' => $current_page, 'extensionApiVersion' => Constants::EXTENSION_API_VERSION, 'pageRegistry' => AdminPageRegistry::boot_payload(), 'debugRestPath' => '/airygen/v1/debug', 'debugEnablePath' => '/airygen/v1/debug/enable', 'debugDisablePath' => '/airygen/v1/debug/disable', 'debugEditorPath' => '/airygen/v1/debug/editor-mode', 'debugLevelPath' => '/airygen/v1/debug/level', 'notifyTimezones' => self::build_timezone_options(), 'migration' => array( 'yoastActive' => $yoast_active, 'rankMathActive' => $rank_math_active, 'aioseoActive' => $aioseo_active, 'seoPressActive' => $seopress_active, ), 'assets' => array( 'logo' => self::plugin_url( 'assets/images/logo.png' ), 'relatedPostsDemoImage' => self::plugin_url( 'assets/images/sample.jpg' ), 'localSeoDemoImage' => self::plugin_url( 'resources/assets/demo/local-business-1600x900.jpg' ), 'localSeoDemoLogoImage' => self::plugin_url( 'resources/assets/demo/local-business-220x88.png' ), ), 'themeStylesheets' => array_values( array_filter( array_unique( array( esc_url_raw( get_stylesheet_uri() ), esc_url_raw( trailingslashit( get_template_directory_uri() ) . 'style.css' ), ) ), static fn( $url ) => is_string( $url ) && '' !== $url ) ), ); $config = apply_filters( Constants::HOOK_ADMIN_BOOT_PAYLOAD, $config, $current_page ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Hook constant is already plugin-prefixed. wp_add_inline_script( 'airygen-admin-app', 'window.airygenAdmin = ' . wp_json_encode( $config, JSON_HEX_TAG ) . ';', 'before' ); } /** * Hide third-party admin notices on plugin dashboard pages to keep the SPA clean. * * @return void */ public static function suppress_notices(): void { if ( ! function_exists( 'get_current_screen' ) ) { return; } $screen = get_current_screen(); if ( ! $screen ) { return; } $ids = AdminPageRegistry::screen_ids(); if ( ! in_array( $screen->id, $ids, true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; $pages = AdminPageRegistry::page_slugs(); if ( ! in_array( $page, $pages, true ) ) { return; } } // Hide third-party notices inside our settings screens. $handle = 'airygen-admin-suppress-notices'; // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Inline-only style; no external file to version. wp_register_style( $handle, false ); wp_enqueue_style( $handle ); wp_add_inline_style( $handle, '#wpbody-content > .notice, #wpbody-content > .updated, #wpbody-content > .error, #wpbody-content > .update-nag{display:none;}' ); } /** * Resolve which SPA page should load for a given admin hook. * * @param string $hook Current admin hook. * * @return string|null */ private static function resolve_current_page( string $hook ): ?string { return AdminPageRegistry::resolve_current_page( $hook ); } /** * Detect whether a plugin is active site-wide or network-wide. * * Reads the WordPress options directly so we don't have to load * `wp-admin/includes/plugin.php`, which is unnecessary on front-end / * REST / CLI requests where this runs. * * @param string $plugin_file Plugin file relative to the plugins directory (e.g. 'wordpress-seo/wp-seo.php'). * @return bool */ private static function is_plugin_active_safe( string $plugin_file ): bool { $active = get_option( 'active_plugins', array() ); if ( is_array( $active ) && in_array( $plugin_file, $active, true ) ) { return true; } if ( is_multisite() ) { $network = get_site_option( 'active_sitewide_plugins', array() ); if ( is_array( $network ) && array_key_exists( $plugin_file, $network ) ) { return true; } } return false; } /** * Detect whether Yoast SEO is active. * * @return bool */ private static function is_yoast_active(): bool { return defined( 'WPSEO_VERSION' ) || self::is_plugin_active_safe( 'wordpress-seo/wp-seo.php' ); } /** * Detect whether Rank Math is active. * * @return bool */ private static function is_rank_math_active(): bool { return defined( 'RANK_MATH_VERSION' ) || self::is_plugin_active_safe( 'rank-math/rank-math.php' ); } /** * Detect whether All in One SEO is active. * * @return bool */ private static function is_aioseo_active(): bool { return defined( 'AIOSEO_VERSION' ) || self::is_plugin_active_safe( 'all-in-one-seo-pack/all_in_one_seo_pack.php' ); } /** * Detect whether SEOPress is active. * * @return bool */ private static function is_seopress_active(): bool { return defined( 'SEOPRESS_VERSION' ) || self::is_plugin_active_safe( 'wp-seopress/seopress.php' ); } /** * Cached application configuration. * * @var array|null */ private static $app_config = null; /** * Load config/app.php as an array. * * @return array * * @phpstan-return array */ private static function app_config(): array { if ( is_array( self::$app_config ) ) { return self::$app_config; } $file = trailingslashit( AIRYGEN_PLUGIN_DIR ) . 'config/app.php'; $config = array(); if ( is_readable( $file ) ) { $data = require $file; if ( is_array( $data ) ) { $config = $data; } } self::$app_config = $config; return self::$app_config; } /** * Enqueue shared Tailwind styles for the admin SPA. * * @return void */ private static function enqueue_styles(): void { $style_path = self::plugin_path( 'build/admin/app.tsx.css' ); if ( ! file_exists( $style_path ) ) { return; } wp_enqueue_style( 'airygen-admin-styles', self::plugin_url( 'build/admin/app.tsx.css' ), array(), (string) filemtime( $style_path ) ); $rtl_style_path = self::plugin_path( 'build/admin/app.tsx-rtl.css' ); if ( file_exists( $rtl_style_path ) ) { wp_style_add_data( 'airygen-admin-styles', 'rtl', 'replace' ); } } /** * Normalize script dependencies for @wordpress/scripts builds. * * @param array $asset Asset metadata. * * @return array */ private static function script_dependencies( array $asset ): array { $dependencies = array(); foreach ( $asset['dependencies'] as $dependency ) { if ( 'react-jsx-runtime' === $dependency ) { $dependencies[] = 'wp-element'; continue; } $dependencies[] = $dependency; } return $dependencies; } /** * Ensure the JSX runtime shims are present when @wordpress/scripts runs standalone. * * @return void */ private static function bootstrap_js_runtime(): void { wp_add_inline_script( 'wp-element', '( function bootstrapJSXRuntime() { if ( ! window.wp || ! window.wp.element ) { setTimeout( bootstrapJSXRuntime, 0 ); return; } var element = window.wp.element; if ( element.jsx && window.ReactJSXRuntime ) { return; } var assign = Object.assign || function assign( target ) { target = target || {}; for ( var index = 1; index < arguments.length; index++ ) { var source = arguments[ index ]; if ( ! source ) { continue; } for ( var key in source ) { if ( Object.prototype.hasOwnProperty.call( source, key ) ) { target[ key ] = source[ key ]; } } } return target; }; var createElement = element.createElement; var Fragment = element.Fragment; if ( ! createElement ) { return; } var factory = function jsxFactory( type, props, key ) { var finalProps = props ? assign( {}, props ) : {}; if ( key !== undefined && key !== null ) { finalProps.key = key; } return createElement( type, finalProps ); }; element.jsx = factory; element.jsxs = factory; element.jsxDEV = factory; window.ReactJSXRuntime = { jsx: factory, jsxs: factory, jsxDEV: factory, Fragment: Fragment, }; } )();', 'after' ); } /** * Build timezone options for notify settings. * * @return array> */ private static function build_timezone_options(): array { $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); $timezones = timezone_identifiers_list(); $options = array(); foreach ( $timezones as $timezone_id ) { if ( ! is_string( $timezone_id ) || '' === $timezone_id ) { continue; } $timezone = new DateTimeZone( $timezone_id ); $offset = $timezone->getOffset( $now ); $options[] = array( 'value' => $timezone_id, 'label' => sprintf( '%s (UTC %s)', $timezone_id, self::format_timezone_offset( $offset ) ), ); } return $options; } /** * Convert seconds offset to a UTC label fragment. * * @param int $offset Offset in seconds. * * @return string */ private static function format_timezone_offset( int $offset ): string { $sign = $offset < 0 ? '-' : '+'; $absolute = abs( $offset ); $hours = (int) floor( $absolute / HOUR_IN_SECONDS ); $minutes = (int) floor( ( $absolute % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS ); if ( 0 === $minutes ) { return sprintf( '%s%d', $sign, $hours ); } return sprintf( '%s%d:%02d', $sign, $hours, $minutes ); } /** * Resolve an absolute plugin path for a relative file. * * @param string $relative Relative path to the file. */ private static function plugin_path( string $relative ): string { return plugin_dir_path( AIRYGEN_PLUGIN_FILE ) . ltrim( $relative, '/' ); } /** * Resolve a plugin URL for a relative asset. * * @param string $relative Relative path to the asset. */ private static function plugin_url( string $relative ): string { return plugins_url( ltrim( $relative, '/' ), AIRYGEN_PLUGIN_FILE ); } /** * Load asset metadata generated by @wordpress/scripts builds. * * @param string $relative Relative path to the asset metadata file. * * @return array{dependencies: array, version: string} */ private static function asset_meta( string $relative ): array { $path = self::plugin_path( $relative ); if ( file_exists( $path ) ) { $data = include $path; if ( is_array( $data ) ) { return array( 'dependencies' => isset( $data['dependencies'] ) && is_array( $data['dependencies'] ) ? $data['dependencies'] : array(), 'version' => isset( $data['version'] ) ? (string) $data['version'] : AIRYGEN_VERSION, ); } } return array( 'dependencies' => array( 'wp-element' ), 'version' => AIRYGEN_VERSION, ); } }