init_hooks(); } private function init_hooks(): void { // Components new AIREWP_Settings(); new AIREWP_Rewrite(); new AIREWP_Dashboard(); AIREWP_Setup::init(); // IndexNow new AIREWP_IndexNow(); // Generators new AIREWP_Agents_JSON(); new AIREWP_Agent_Card(); new AIREWP_LLMS_Txt(); new AIREWP_JSON_LD(); // REST API new AIREWP_REST_API(); // AI Bot Management in robots.txt (v3.0) new AIREWP_Robots_Txt(); // AI Crawler Analytics (v3.0) new AIREWP_Crawler_Analytics(); // WP Abilities API integration — WP 6.9+ (v3.0) new AIREWP_Abilities(); // v4.0 Modules new AIREWP_SEO(); // Suggested privacy policy text (Settings → Privacy) add_action( 'admin_init', [ $this, 'add_privacy_policy_content' ] ); // Rebuild the generated files right after a plugin update. Updating a // plugin runs neither the activation hook nor save_post, so without this // an llms-full.txt written by an older version would sit on disk // unchanged until someone saved a post or the daily cron happened to // run — up to a day of serving stale content on a security update. add_action( 'init', [ $this, 'maybe_upgrade' ], 5 ); add_action( 'upgrader_process_complete', [ $this, 'on_plugin_updated' ], 10, 2 ); // Regenerate files when settings or content change add_action( 'airewp_settings_updated', [ $this, 'regenerate_all' ] ); add_action( 'save_post', [ $this, 'on_content_change' ], 20, 2 ); // Fires before save_post and carries the previous state, which is the // only place the "was public, is now protected" transition is visible. add_action( 'post_updated', [ $this, 'note_password_transition' ], 10, 3 ); add_action( 'delete_post', [ $this, 'schedule_regeneration' ] ); add_action( 'airewp_cron_regenerate', [ $this, 'regenerate_all' ] ); // Schedule daily regeneration if ( ! wp_next_scheduled( 'airewp_cron_regenerate' ) ) { wp_schedule_event( time(), 'daily', 'airewp_cron_regenerate' ); } } /** * Get plugin settings with defaults. */ public static function get_settings(): array { $defaults = [ // Business info 'company_name' => get_bloginfo( 'name' ), 'company_desc' => get_bloginfo( 'description' ), 'company_url' => home_url(), 'company_email' => get_option( 'admin_email' ), 'company_phone' => '', 'company_address' => '', 'company_city' => '', 'company_zip' => '', 'company_country' => '', 'company_logo' => '', 'industry' => 'general', 'languages' => [ get_locale() ], // Services (comma-separated) 'services' => '', // Social 'social_x' => '', 'social_linkedin' => '', 'social_instagram' => '', 'social_facebook' => '', 'social_github' => '', // Features toggles 'enable_agents_json' => true, 'enable_agent_card' => true, 'enable_llms_txt' => true, 'enable_json_ld' => true, 'enable_indexnow' => true, 'enable_rest_api' => true, 'enable_robots_ai' => true, // v3.0 'enable_crawler_analytics' => true, // v3.0 'enable_mcp_json' => false, // v4.2 // Robots.txt AI policy (v3.0) 'robots_block_training' => true, // Block training bots by default 'robots_block_retrieval' => false, // Allow retrieval/search bots // IndexNow 'indexnow_key' => '', // JSON-LD 'schema_type' => 'Organization', // Organization or LocalBusiness // v4.0 — SEO Module 'enable_seo' => true, // MCP servers (JSON array) 'mcp_servers' => '', // Advanced 'custom_tools' => '', 'custom_skills' => '', ]; $saved = get_option( AIREWP_OPTION_KEY, [] ); return wp_parse_args( $saved, $defaults ); } /** * Suggest privacy policy text (shown under Settings → Privacy). */ public function add_privacy_policy_content(): void { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $content = '

' . __( 'AI-Ready WP does not collect, store, or transmit personal data about your visitors. It does not set cookies and does not track human visitors.', 'ai-ready-wp' ) . '

'; $content .= '

' . __( 'The AI Crawler Analytics feature counts visits from known AI bots based on their User-Agent string only; no IP addresses or other personal information are stored.', 'ai-ready-wp' ) . '

'; $content .= '

' . __( 'When IndexNow is enabled, public URLs of your content are sent to the IndexNow API (api.indexnow.org) to notify search engines about updates. No visitor data is included in these requests.', 'ai-ready-wp' ) . '

'; $content .= '

' . __( 'The generated discovery files (agents.json, agent-card.json, llms.txt) only contain the business information you enter in the plugin settings and public content from your site.', 'ai-ready-wp' ) . '

'; wp_add_privacy_policy_content( 'AI-Ready WP', wp_kses_post( wpautop( $content, false ) ) ); } /** * Throttles the post-upgrade rebuild on sites that cannot write files. */ private const UPGRADE_LOCK = 'airewp_upgrade_rebuild'; /** * Post IDs that changed from public to password-protected in this request. * * @var array */ private array $just_locked = []; /** * Post IDs that were public before this save and are not any more. * * @var array */ private array $just_left_public = []; /** * Whether a rebuild is queued for the end of this request. */ private bool $regeneration_pending = false; /** * Result of the last llms.txt write or delete in regenerate_all(). * * The upgrade rebuild exists to get rid of a leaking llms-full.txt, so this * is what it waits for. Gating on "every single generated file succeeded" * looked stricter but meant one unwritable agents.json — a root-owned * .well-known/ is what certbot leaves behind — kept the site rebuilding * every hour, forever, without ever counting as updated. */ private bool $last_llms_ok = true; /** * Rebuild generated files after the plugin files were replaced. * * The version is recorded only once the rebuild has verifiably reached the * disk. Writing it upfront would be the more comfortable order, but * write_file() can legitimately fail — a read-only filesystem, or a site * where WordPress falls back to an FTP transport without credentials — and * then the file written by the vulnerable version would stay in place while * the plugin considered the upgrade finished. The transient keeps a site * that cannot write from retrying on every single request. */ public function maybe_upgrade(): void { if ( AIREWP_VERSION === get_option( 'airewp_generated_version' ) ) { return; } if ( get_transient( self::UPGRADE_LOCK ) ) { return; } set_transient( self::UPGRADE_LOCK, 1, HOUR_IN_SECONDS ); $this->regenerate_all(); // Gated on the llms.txt files alone, not on every generated file. Those // two are what the vulnerable version leaked through; an agents.json // that cannot be written is a separate, non-security problem and must // not keep this rebuild retrying for the rest of the site's life. if ( $this->last_llms_ok ) { update_option( 'airewp_generated_version', AIREWP_VERSION, true ); } } /** * Delete the generated files when this plugin is updated. * * Runs inside the request that performs the update, so the files from the * previous version are gone the moment its code is replaced; the next page * load rebuilds them. Note this cannot help the update that installs it — * during that request the OLD version is the code in memory, and the old * version has no such hook. For the 4.3.2 to 4.3.3 step the earliest * possible moment really is the first request afterwards, which is what * maybe_upgrade() covers. * * @param mixed $upgrader Upgrader instance (unused). * @param array $options Upgrade context. */ public function on_plugin_updated( $upgrader, $options ): void { if ( ! is_array( $options ) ) { return; } if ( ( $options['action'] ?? '' ) !== 'update' || ( $options['type'] ?? '' ) !== 'plugin' ) { return; } if ( ! in_array( AIREWP_PLUGIN_BASENAME, (array) ( $options['plugins'] ?? [] ), true ) ) { return; } // Deleting, not regenerating: the generator in memory belongs to the // version being replaced. A file that is gone cannot serve stale // content, and maybe_upgrade() writes fresh ones on the next request. foreach ( [ 'llms.txt', 'llms-full.txt' ] as $generated ) { self::delete_file( self::get_output_dir() . $generated ); } delete_option( 'airewp_generated_version' ); delete_transient( self::UPGRADE_LOCK ); } /** * Remember posts that just went from public to password-protected. * * @param int $post_id Post ID. * @param \WP_Post $post_after Post after the update. * @param \WP_Post $post_before Post before the update. */ public function note_password_transition( int $post_id, $post_after, $post_before ): void { if ( ! $post_after instanceof \WP_Post || ! $post_before instanceof \WP_Post ) { return; } $was_public = 'publish' === $post_before->post_status && '' === (string) $post_before->post_password; $is_public = 'publish' === $post_after->post_status && '' === (string) $post_after->post_password; if ( '' === (string) $post_before->post_password && '' !== (string) $post_after->post_password ) { $this->just_locked[ $post_id ] = true; } // Only a post that WAS public and no longer is needs the immediate // rebuild. Keying it off "not published" alone made every auto-draft — // one is created the moment someone clicks "Add New Post" — and every // save of an unrelated custom post type rebuild all files synchronously. if ( $was_public && ! $is_public ) { $this->just_left_public[ $post_id ] = true; } } /** * Set by write_file()/delete_file() whenever an operation fails. * * Sniffing the finished file for a version string was the first attempt and * it was fooled by its own input: post content that happens to contain * "AI-Ready WP " made a stale file look current. Asking the writes * themselves is the honest question, and it covers every generated file * rather than the one that was checked. */ private static bool $write_failed = false; /** * Regenerate all static files. * * @return bool True when every write and delete in this run succeeded. */ public function regenerate_all(): bool { // No cross-request lock, on purpose, after one was built and removed. // // Two rebuilds in parallel can still interleave, and the later one may // have read the post list a moment earlier — the file is then one change // out of date until the next rebuild. That is the whole cost. // // Guarding it with a WordPress option made things strictly worse, and it // is worth writing down why so nobody rebuilds it: add_option() consults // the per-request options cache, so a waiting loop keeps seeing the value // it read first and never acquires anything; the same cache made the // ownership check compare against our own copy rather than what is in the // database; a run that stood down could not be told apart from one that // rebuilt, so the upgrade recorded itself as finished without ever // writing — leaving exactly the leaking file this release exists to // remove; and a rebuild killed by a fatal left the lock behind, which // silenced every rebuild for the next minute, including the one that // locks a post. // // A real lock needs a raw INSERT IGNORE, which is what WordPress core // uses for upgrades. Not worth it here: a file that is one change stale // for a few hundred milliseconds is a smaller problem than a rebuild // that silently does not happen. self::$write_failed = false; // Reset before the run so the upgrade gate can never read a value left // over from an earlier one. The version must only be recorded on the // strength of a rebuild that actually happened in this call. $this->last_llms_ok = false; $settings = self::get_settings(); // agents.json if ( $settings['enable_agents_json'] ) { AIREWP_Agents_JSON::generate( $settings ); } // agent-card.json if ( $settings['enable_agent_card'] ) { AIREWP_Agent_Card::generate( $settings ); } else { foreach ( [ 'agent-card.json', 'agent.json' ] as $card_name ) { $card_file = self::get_well_known_dir() . $card_name; self::delete_file( $card_file ); } } // llms.txt — when the feature is switched off the files must go, the // same way agent-card.json and mcp.json are removed below. Without the // else branch a site that generated llms-full.txt and then disabled the // feature kept serving the old file forever. if ( $settings['enable_llms_txt'] ) { $this->last_llms_ok = AIREWP_LLMS_Txt::generate( $settings ); } else { $llms_ok = true; foreach ( [ 'llms.txt', 'llms-full.txt' ] as $llms_file ) { // Deliberately not short-circuiting: both files must be tried. $llms_ok = self::delete_file( self::get_output_dir() . $llms_file ) && $llms_ok; } $this->last_llms_ok = $llms_ok; } // mcp.json if ( $settings['enable_mcp_json'] ) { AIREWP_MCP_JSON::generate( $settings ); } else { self::delete_file( self::get_well_known_dir() . 'mcp.json' ); } // IndexNow if ( $settings['enable_indexnow'] && ! empty( $settings['indexnow_key'] ) ) { AIREWP_IndexNow::generate_key_file( $settings['indexnow_key'] ); } // Read the result BEFORE the hook: a listener that calls // regenerate_all() again would reset the flag and turn a failed run // into a reported success. $ok = ! self::$write_failed; do_action( 'airewp_files_regenerated', $settings ); return $ok; } /** * Handle content changes — regenerate files and ping IndexNow. */ public function on_content_change( int $post_id, \WP_Post $post ): void { // Skip revisions, autosaves, non-public post types if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } $was_just_locked = ! empty( $this->just_locked[ $post_id ] ); unset( $this->just_locked[ $post_id ] ); // Every save queues a rebuild, including the ones that REMOVE a post // from the public set. The old code returned here for anything that is // not published, so a post moved to draft kept its full text in // llms-full.txt. Locking a post has to rebuild for the same reason. $this->schedule_regeneration(); // A save that takes content out of the public set does not wait for // shutdown — see regenerate_before_response(). Note this asks about the // TRANSITION, recorded on post_updated, not about the current status: // "not published" on its own is true for every auto-draft as well. if ( ! empty( $this->just_left_public[ $post_id ] ) ) { unset( $this->just_left_public[ $post_id ] ); $this->regenerate_before_response(); } if ( 'publish' !== $post->post_status ) { return; } // Password-protected posts are normally not announced: the URL only // serves a password form, and a ping would point Bing and Yandex at // content that was never public. // // The exception matters more than the rule. A post that was public and // has just been locked is already sitting in those indexes with its old // text. There the ping is the privacy-preserving move: it asks for a // recrawl, the crawler finds the password form and drops the snippet. // Staying silent would leave the old content in the index until some // later recrawl. if ( '' !== (string) $post->post_password && ! $was_just_locked ) { return; } // Ping IndexNow for this specific URL $settings = self::get_settings(); if ( $settings['enable_indexnow'] && ! empty( $settings['indexnow_key'] ) ) { AIREWP_IndexNow::ping_url( get_permalink( $post_id ), $settings ); } } /** * Rebuild the generated files once, at the end of this request. * * The previous version asked wp_next_scheduled('airewp_cron_regenerate') * before scheduling a single event under THAT SAME hook name — and the * daily recurring event is registered under it, so the answer was always * "yes, already scheduled" and the one-off event was never created. The * method looked like a debounce and was a no-op: locking a post did not * rebuild anything, and its text stayed in llms-full.txt until the daily * cron happened to run. That file is served straight off disk by the web * server, so no visitor request has to touch WordPress in the meantime. * * Running on 'shutdown' instead of via cron removes the dependency on * WP-Cron entirely, collapses a bulk edit of fifty posts into one rebuild, * and sees the final state of every post in this request. */ public function schedule_regeneration(): void { $already_queued = $this->regeneration_pending; $this->regeneration_pending = true; if ( ! $already_queued ) { add_action( 'shutdown', [ $this, 'run_pending_regeneration' ], 5 ); } } /** * Run the rebuild queued by schedule_regeneration(). * * Loops rather than running once: a listener on airewp_files_regenerated * that saves a post would queue another rebuild while this one is still * inside the shutdown hook, and re-registering the same callback on a hook * that is already running does not make it run again. The flag would stay * set and the rebuild would never happen. Two passes is the cap — one * follow-up is enough for a real cascade, and it cannot spin. */ public function run_pending_regeneration(): void { $passes = 0; while ( $this->regeneration_pending && $passes < 2 ) { $this->regeneration_pending = false; $passes++; $this->regenerate_all(); } } /** * Rebuild right now, before this request answers. * * Used for the saves that REMOVE a post from the public set. The rebuild at * shutdown runs after the response has gone out, which leaves a window * where the editor has already been told "saved" while llms-full.txt still * carries the old text. * * Every removal does this, not just the first in a request. The first * version capped it at one, on the assumption that a bulk edit would * otherwise run into a timeout — measured instead of assumed: on a site * with 100 posts and 100 pages (a 271 KB llms-full.txt) one rebuild takes * 52 ms, so fifty of them cost 2.6 seconds. The cap bought nothing and left * every post after the first exposed until the end of the request. */ private function regenerate_before_response(): void { $this->regenerate_all(); } /** * Get the output directory for generated files. */ public static function get_output_dir(): string { return AIREWP_ROOT_PATH; } /** * Prepare $wp_filesystem and return it, or null if it is unavailable. * * The old inline version assumed two things that do not always hold: that * wp-admin/includes/file.php is loaded whenever $wp_filesystem is set, and * that FS_CHMOD_FILE exists once WP_Filesystem() has run. WordPress only * defines that constant at the very end of WP_Filesystem(), after a * successful connect — and under WP-CLI the global can already be set by * something else, so the call is skipped and the constant never appears. * Using it then is a fatal error, which is exactly what happened on the * first WP-CLI command after an update. */ public static function filesystem(): ?object { global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( empty( $wp_filesystem ) ) { WP_Filesystem(); } if ( empty( $wp_filesystem ) ) { return null; } // "Not empty" is not the same as "usable". WP_Filesystem() assigns the // transport object BEFORE it tries to connect, so a failed connection — // an FTP transport without credentials, a missing ftp extension — leaves // a fully constructed object behind that fatals on the first call // (ftp_pwd() undefined). Both cases record a WP_Error on the object, so // that is what decides whether it can be used. if ( isset( $wp_filesystem->errors ) && is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->has_errors() ) { return null; } // Last line of defence for a transport we do not recognise at all. if ( ! method_exists( $wp_filesystem, 'exists' ) || ! method_exists( $wp_filesystem, 'put_contents' ) ) { return null; } return $wp_filesystem; } /** * Get the .well-known directory, creating it if needed. */ public static function get_well_known_dir(): string { $dir = AIREWP_ROOT_PATH . '.well-known/'; $fs = self::filesystem(); if ( null === $fs || ! $fs->is_dir( $dir ) ) { wp_mkdir_p( $dir ); } return $dir; } /** * Write a file using WP_Filesystem. */ public static function write_file( string $path, string $content ): bool { $fs = self::filesystem(); if ( null === $fs ) { self::$write_failed = true; return false; } // put_contents() takes false for "use the default mode". $mode = defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : false; $ok = (bool) $fs->put_contents( $path, $content, $mode ); if ( ! $ok ) { self::$write_failed = true; } return $ok; } /** * Delete a file using WP_Filesystem. */ public static function delete_file( string $path ): bool { $fs = self::filesystem(); if ( null === $fs ) { self::$write_failed = true; return false; } if ( $fs->exists( $path ) ) { $ok = (bool) $fs->delete( $path ); if ( ! $ok ) { self::$write_failed = true; } return $ok; } return true; } } // Activation register_activation_hook( __FILE__, function (): void { // Generate IndexNow key on activation $settings = AI_Ready_WP::get_settings(); if ( empty( $settings['indexnow_key'] ) ) { $settings['indexnow_key'] = bin2hex( random_bytes( 32 ) ); update_option( AIREWP_OPTION_KEY, $settings ); } // Flush rewrite rules AIREWP_Rewrite::add_rules(); flush_rewrite_rules(); // Generate all files AI_Ready_WP::instance()->regenerate_all(); // Trigger the first-run setup wizard on the next admin load. AIREWP_Setup::flag_activation(); } ); // Deactivation register_deactivation_hook( __FILE__, function (): void { wp_clear_scheduled_hook( 'airewp_cron_regenerate' ); flush_rewrite_rules(); } ); // Boot AI_Ready_WP::instance();