options = $options; add_action( 'admin_init', [ $this, 'init' ] ); } /** * Start the engine running * * @return void */ public function init() { $this->settings_options = [ [ 'option_group' => Ads_Robots_Txt::OPTION_NAME, 'option_name' => Ads_Robots_Txt::OPTION_NAME, 'args' => [ 'sanitize_callback' => [ $this, 'sanitize_settings' ] ], ], ]; $this->settings_sections = [ [ 'id' => 'adminoptimizer-robotstxt-section', 'title' => __( 'Robots.txt', 'admin-optimizer' ), // translators: %1$s is the anchor tag to the robots.txt resource. %2$s is the anchor closure tag. 'description' => sprintf( __( 'A robots.txt file lists a website\'s preferences for bot behavior. It tells bots which webpages they should and should not access. Robots.txt files are most relevant for web crawlers. %1$sLearn more%2$s', 'admin-optimizer' ), '', '' ), 'menu_slug' => Ads_Robots_Txt::OPTION_NAME, 'option_name' => Ads_Robots_Txt::OPTION_NAME, 'fields' => [ [ 'type' => 'callback', 'title' => __( 'AI Bot Policy', 'admin-optimizer' ), 'id' => 'ai-bot-policy', 'name' => 'ai_bot_policy', 'callback' => [ $this, 'render_ai_bot_policy_controls' ], ], [ 'type' => 'callback', 'title' => __( 'Load Preset', 'admin-optimizer' ), 'id' => 'robotstxt-preset-loader', 'name' => 'robotstxt_preset_loader', 'callback' => [ $this, 'render_robotstxt_preset_buttons' ], ], [ 'type' => 'code-editor', 'title' => __( 'Robots.txt Content', 'admin-optimizer' ), 'id' => 'robotstxt-content', 'name' => 'robotstxt_content', 'code_type' => 'text/plain', 'code_theme' => 'eclipse', // translators: %1$s is the anchor tag to the robots.txt. %2$s is the anchor closure tag. 'desc' => sprintf( __( 'Leave it blank to disable it. %1$sView the robots.txt here%2$s.', 'admin-optimizer' ), '', '' ), ], ], ], [ 'id' => 'adminoptimizer-adstxt-section', 'title' => __( 'Ads.txt', 'admin-optimizer' ), 'description' => __( 'ads.txt is an IAB Tech Lab initiative that helps ensure that your digital ad inventory is only sold through sellers who you\'ve identified as authorized. Creating your own ads.txt file gives you more control over who\'s allowed to sell ads on your site and helps prevent counterfeit inventory from being presented to advertisers.', 'admin-optimizer' ), 'menu_slug' => Ads_Robots_Txt::OPTION_NAME, 'option_name' => Ads_Robots_Txt::OPTION_NAME, 'fields' => [ [ 'type' => 'code-editor', 'title' => __( 'Ads.txt Content', 'admin-optimizer' ), 'id' => 'adstxt-content', 'name' => 'adstxt_content', 'code_type' => 'text/plain', 'code_theme' => 'eclipse', // translators: %1$s is the anchor tag to the app-ads.txt. %2$s is the anchor closure tag. %3$s is the anchor tag to the validator. %4$s is the anchor closure tag. 'desc' => sprintf( __( 'Leave it blank to disable it. %1$sView the ads.txt here%2$s. Validate with %3$sadstxt.guru%4$s', 'admin-optimizer' ), '', '', '', '' ), ], ], ], [ 'id' => 'adminoptimizer-appadstxt-section', 'title' => __( 'App-ads.txt', 'admin-optimizer' ), 'description' => __( 'The app-ads.txt file is a publicly accessible text file that app developers place on their official website to declare which ad networks and platforms are authorized to sell their app\'s ad inventory. This helps prevent ad fraud, ensuring that only legitimate sellers can monetize the app\'s ad space.', 'admin-optimizer' ), 'menu_slug' => Ads_Robots_Txt::OPTION_NAME, 'option_name' => Ads_Robots_Txt::OPTION_NAME, 'fields' => [ [ 'type' => 'code-editor', 'title' => __( 'App-ads.txt Content', 'admin-optimizer' ), 'id' => 'app-adstxt-content', 'name' => 'app_adstxt_content', 'code_type' => 'text/plain', 'code_theme' => 'eclipse', // translators: %1$s is the anchor tag to the app-ads.txt. %2$s is the anchor closure tag. %3$s is the anchor tag to the validator. %4$s is the anchor closure tag. 'desc' => sprintf( __( 'Leave it blank to disable it. %1$sView the app-ads.txt here%2$s. Validate with %3$sadstxt.guru%4$s', 'admin-optimizer' ), '', '', '', '' ), ], ], ], ]; $this->setup(); } /** * Enqueue settings-page assets before the admin head is rendered. * * @param string $hook_suffix Current admin page hook suffix. */ public function enqueue_settings_assets( string $hook_suffix ): void { $robotstxt_editor = wp_enqueue_code_editor( [ 'type' => 'text/robotstxt' ] ); $adstxt_editor = wp_enqueue_code_editor( [ 'type' => 'text/adstxt' ] ); wp_enqueue_style( 'adminoptim-module-settings' ); wp_enqueue_style( 'adminoptim-robots-txt', Ads_Robots_Txt::MODULE_URL . 'assets/css/robots-txt.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptim-codemirror-simple-mode', Ads_Robots_Txt::MODULE_URL . 'assets/js/simple-mode.min.js', [ 'wp-codemirror' ], ADMINOPTIMIZER_VERSION, false ); wp_enqueue_script( 'adminoptim-ads-robots-codemirror-modes', Ads_Robots_Txt::MODULE_URL . 'assets/js/cm-modes.min.js', [ 'adminoptim-codemirror-simple-mode', 'code-editor' ], ADMINOPTIMIZER_VERSION, false ); if ( false !== $robotstxt_editor && false !== $adstxt_editor ) { $robotstxt_editor['codemirror']['theme'] = 'eclipse'; $adstxt_editor['codemirror']['theme'] = 'eclipse'; wp_add_inline_script( 'adminoptim-ads-robots-codemirror-modes', sprintf( 'jQuery(function(){wp.codeEditor.initialize("robotstxt-content",%1$s);wp.codeEditor.initialize("adstxt-content",%2$s);wp.codeEditor.initialize("app-adstxt-content",%2$s);});', wp_json_encode( $robotstxt_editor ), wp_json_encode( $adstxt_editor ) ) ); } $preset_data = []; foreach ( self::get_robots_txt_presets() as $key => $preset ) { $preset_data[ $key ] = $preset['content']; } wp_add_inline_script( 'adminoptim-ads-robots-codemirror-modes', sprintf( 'jQuery(function(){var p=%s;document.addEventListener("click",function(e){var btn=e.target.closest(".adminoptim-robotstxt-preset-btn");if(!btn)return;var v=p[btn.dataset.preset];if(v===undefined)return;var ta=document.getElementById("robotstxt-content");if(!ta)return;var cw=ta.nextElementSibling;var cm=cw&&cw.CodeMirror?cw.CodeMirror:null;if(cm){var existing=cm.getValue().trim();cm.setValue(existing?existing+"\n\n"+v:v);}});});', wp_json_encode( $preset_data ) ) ); $this->localize_ai_bot_policy( 'adminoptim-ads-robots-codemirror-modes' ); } /** * Precompute the policy data used by the editor-side merge. The PHP helper * remains the canonical implementation used by tests and future server-side * callers; this small client equivalent keeps Apply unsaved as designed. * * @param string $handle Script handle to attach the inline data to. * @return void */ private function localize_ai_bot_policy( string $handle ): void { $policy_blocks = []; $signals = []; foreach ( [ AI_Bot_Policy::POLICY_ALLOW, AI_Bot_Policy::POLICY_BLOCK ] as $search ) { foreach ( [ AI_Bot_Policy::POLICY_ALLOW, AI_Bot_Policy::POLICY_BLOCK ] as $agent ) { foreach ( [ AI_Bot_Policy::POLICY_ALLOW, AI_Bot_Policy::POLICY_BLOCK ] as $training ) { $key = "$search-$agent-$training"; $policy = [ AI_Bot_Registry::CATEGORY_SEARCH => $search, AI_Bot_Registry::CATEGORY_AGENT => $agent, AI_Bot_Registry::CATEGORY_TRAINING => $training, ]; $policy_blocks[ $key ] = AI_Bot_Policy::build_block( $policy ); $signals[ $key ] = AI_Bot_Policy::content_signal( $policy ); } } } wp_add_inline_script( $handle, sprintf( 'window.aoAiBotPolicy=%s;', wp_json_encode( [ 'blocks' => $policy_blocks, 'signals' => $signals, 'initial' => AI_Bot_Policy::detect_policy( (string) ( $this->options['robotstxt_content'] ?? '' ) ), 'markers' => [ 'start' => AI_Bot_Policy::BLOCK_START, 'end' => AI_Bot_Policy::BLOCK_END, ], ] ) ), 'before' ); wp_add_inline_script( $handle, 'jQuery(function(){' . 'var wrap=document.querySelector(".adminoptim-ai-bot-policy");if(!wrap)return;' . 'var data=window.aoAiBotPolicy;if(!data)return;' . 'var labels={allow:"Allow",block:"Block"};' . 'var applyBtn=wrap.querySelector(".adminoptim-ai-bot-policy-apply");' . 'var statusEl=wrap.querySelector(".adminoptim-ai-bot-policy-status");' . 'var applied=false,custom=data.initial&&data.initial.status==="custom";' . 'function state(){var s={};wrap.querySelectorAll(".adminoptim-ai-bot-policy-row").forEach(function(row){var input=row.querySelector(".adminoptim-ai-bot-policy-value");s[row.dataset.category]=input?input.value:"allow";});return s;}' . 'function renderStatus(){' . 'var s=state();' . 'var names={search:"Search",agent:"AI Input",training:"Training"};var parts=["search","agent","training"].map(function(c){return names[c]+": "+(s[c]==="allow"?"Allow":"Disallow");});' . 'statusEl.textContent=custom?"Current robots.txt contains a custom or partially recognized AI policy.":parts.join(" \\u00b7 ")+(applied?" \\u2014 applied to editor. Save changes to publish.":" \\u2014 not yet applied");' . '}' . 'wrap.querySelectorAll(".adminoptim-ai-bot-policy-pill-btn").forEach(function(pillBtn){' . 'pillBtn.addEventListener("click",function(){' . 'var row=pillBtn.closest(".adminoptim-ai-bot-policy-row");if(!row)return;' . 'row.querySelectorAll(".adminoptim-ai-bot-policy-pill-btn").forEach(function(b){b.classList.remove("is-active");});' . 'pillBtn.classList.add("is-active");' . 'var input=row.querySelector(".adminoptim-ai-bot-policy-value");if(input)input.value=pillBtn.dataset.value;' . 'applied=false;custom=false;renderStatus();' . '});' . '});' . 'if(applyBtn)applyBtn.addEventListener("click",function(){' . 'var s=state();var key=s.search+"-"+s.agent+"-"+s.training;var block=data.blocks[key],signal=data.signals[key];if(block===undefined||signal===undefined)return;' . 'var ta=document.getElementById("robotstxt-content");if(!ta)return;' . 'var cw=ta.nextElementSibling;var cm=cw&&cw.CodeMirror?cw.CodeMirror:null;' . 'var current=cm?cm.getValue():ta.value,lines=current.split(/\\r\\n|\\r|\\n/),wild=[],active=-1;lines.forEach(function(line,i){if(/^\\s*User-agent\\s*:\\s*\\*\\s*(?:#.*)?$/i.test(line)){wild.push(i);active=i;}else if(/^\\s*User-agent\\s*:/i.test(line)){active=-1;}});if(wild.length>1){custom=true;renderStatus();return;}' . 'var esc=function(v){return v.replace(/[.*+?^${}()|[\\]\\\\]/g,"\\\\$&");};var pattern=new RegExp(esc(data.markers.start)+"[\\\\s\\\\S]*?"+esc(data.markers.end)+"\\\\s*","g");current=current.replace(pattern,"");lines=current.split(/\\r\\n|\\r|\\n/);wild=[];lines.forEach(function(line,i){if(/^\\s*User-agent\\s*:\\s*\\*\\s*(?:#.*)?$/i.test(line))wild.push(i);});' . 'if(wild.length){var start=wild[0],remove=[];for(var i=start+1;i=0;j--)lines.splice(remove[j],1);lines.splice(start+1,0,signal);}else if(current.trim()){lines.push("","User-agent: *",signal);}else{lines=["User-agent: *",signal];}' . 'var next=lines.join("\\n").replace(/\\s+$/,"");if(block)next+=(next?"\\n\\n":"")+block;' . 'if(cm){cm.setValue(next);}else{ta.value=next;}' . 'applied=true;renderStatus();' . '});' . 'renderStatus();' . '});' ); } /** * Render Settings page * * @return void */ public function render_settings_page() { ?>
render_physical_file_notice(); ?> render_settings_on_page( Ads_Robots_Txt::OPTION_NAME ); ?>

%s

', esc_html( sprintf( /* translators: %s is a comma-separated list of filenames found in the site's document root. */ __( 'A physical file already exists in your site\'s document root for: %s. Most web servers will serve that file directly, so the content configured below will not be used until it is removed.', 'admin-optimizer' ), implode( ', ', $overridden ) ) ) ); } /** * Returns robots.txt preset template definitions. * * @return array */ private static function get_robots_txt_presets(): array { return [ 'wp_default' => [ 'label' => __( 'WordPress Default', 'admin-optimizer' ), 'content' => implode( "\n", [ 'User-agent: *', 'Disallow: /wp-admin/', 'Allow: /wp-admin/admin-ajax.php', ] ), ], 'allow_all' => [ 'label' => __( 'Allow All', 'admin-optimizer' ), 'content' => "User-agent: *\nDisallow:", ], 'block_all' => [ 'label' => __( 'Block All Bots', 'admin-optimizer' ), 'content' => "User-agent: *\nDisallow: /", ], 'seo_optimized' => [ 'label' => __( 'SEO Optimized', 'admin-optimizer' ), 'content' => implode( "\n", [ 'User-agent: *', 'Disallow: /wp-admin/', 'Disallow: /wp-includes/', 'Disallow: /?s=', 'Disallow: /search/', 'Allow: /wp-admin/admin-ajax.php', 'Allow: /wp-content/uploads/', ] ), ], 'add_sitemap' => [ 'label' => __( 'Add Sitemap', 'admin-optimizer' ), 'content' => 'Sitemap: ' . home_url( '/sitemap.xml' ), ], ]; } /** * Returns AI bot policy category display metadata: label, short icon * letter, accent color, and plain-language description of what each * category covers. * * @return array */ private static function get_ai_bot_policy_category_meta(): array { return [ AI_Bot_Registry::CATEGORY_SEARCH => [ 'label' => __( 'Search', 'admin-optimizer' ), 'block_label' => __( 'Block', 'admin-optimizer' ), 'letter' => 'S', 'color' => '#2271b1', 'desc' => __( 'Search engines that index your site for search results, e.g. Googlebot, Bingbot, DuckDuckBot.', 'admin-optimizer' ), ], AI_Bot_Registry::CATEGORY_AGENT => [ 'label' => __( 'AI Input / Agents', 'admin-optimizer' ), 'block_label' => __( 'Block', 'admin-optimizer' ), 'letter' => 'A', 'color' => '#8c5ea8', 'desc' => __( 'Controls whether AI assistants and agents may use your content when answering user requests, e.g. ChatGPT-User, PerplexityBot, Claude-User.', 'admin-optimizer' ), ], AI_Bot_Registry::CATEGORY_TRAINING => [ 'label' => __( 'Training', 'admin-optimizer' ), 'block_label' => __( 'Disallow', 'admin-optimizer' ), 'letter' => 'T', 'color' => '#b26200', 'desc' => __( 'Crawlers that harvest content to train AI models, e.g. GPTBot, Google-Extended, CCBot.', 'admin-optimizer' ), ], ]; } /** * Render the AI Bot Policy card: Search / AI Input / Training rows, each * with an Allow/Block segmented control, and an "Apply to Robots.txt" * button. Purely client-side — selections are spliced into the live * CodeMirror instance by inline JS; nothing here posts back to the * server until the page's own Save Changes button runs * sanitize_settings(). Each pill's underlying value is carried by a * hidden input so the choice still posts with the form even before JS * has run. * * @param array $_field Field definition. * @return void */ public function render_ai_bot_policy_controls( array $_field ): void { $detected = AI_Bot_Policy::detect_policy( (string) ( $this->options['robotstxt_content'] ?? '' ) ); $policy = AI_Bot_Policy::sanitize_policy( $detected['policy'] ?? ( $this->options['ai_bot_policy'] ?? [] ) ); $meta = self::get_ai_bot_policy_category_meta(); echo '
'; echo '
'; echo '' . esc_html__( 'AI Bot Policy', 'admin-optimizer' ) . ''; echo '' . esc_html__( 'New', 'admin-optimizer' ) . ''; echo '
'; echo '

' . esc_html__( 'Choose Allow or Block per category below, then Apply to write a managed block into Robots.txt Content: a Content-Signal line every crawler can read, plus named Disallow rules for bots that don\'t honor Content-Signal yet.', 'admin-optimizer' ) . '

'; echo '
'; foreach ( $meta as $category => $info ) { printf( '
', esc_attr( $category ) ); printf( '', esc_attr( $info['color'] ), esc_html( $info['letter'] ) ); echo ''; printf( '%s', esc_html( $info['label'] ) ); printf( '%s', esc_html( $info['desc'] ) ); echo ''; printf( '', /* translators: %s is the category name, e.g. "Search". */ esc_attr( sprintf( __( '%s policy', 'admin-optimizer' ), $info['label'] ) ) ); printf( '', AI_Bot_Policy::POLICY_ALLOW === $policy[ $category ] ? ' is-active' : '', esc_attr( AI_Bot_Policy::POLICY_ALLOW ), esc_html__( 'Allow', 'admin-optimizer' ) ); printf( '', AI_Bot_Policy::POLICY_BLOCK === $policy[ $category ] ? ' is-active' : '', esc_attr( AI_Bot_Policy::POLICY_BLOCK ), esc_html( $info['block_label'] ) ); echo ''; printf( '', esc_attr( Ads_Robots_Txt::OPTION_NAME ), esc_attr( $category ), esc_attr( $policy[ $category ] ) ); echo '
'; } echo '
'; echo ''; echo '
'; } /** * Render robots.txt preset buttons. * * @param array $_field Field definition. * @return void */ public function render_robotstxt_preset_buttons( array $_field ): void { $presets = self::get_robots_txt_presets(); echo '
'; foreach ( $presets as $key => $preset ) { printf( '', esc_attr( $key ), esc_html( $preset['label'] ) ); } echo '
'; } /** * Callback function to sanitize user options * * @param array $options User options. * * @return array */ public function sanitize_settings( $options ) { $sanitized_options = []; if ( is_array( $options ) ) { foreach ( [ 'robotstxt_content', 'adstxt_content', 'app_adstxt_content' ] as $key ) { if ( isset( $options[ $key ] ) ) { $sanitized_options[ $key ] = sanitize_textarea_field( wp_unslash( $options[ $key ] ) ); } } $sanitized_options['ai_bot_policy'] = AI_Bot_Policy::sanitize_policy( $options['ai_bot_policy'] ?? [] ); } return $sanitized_options; } }