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();
// Admin REST API for the site owner's own AI (v4.5). Registered
// whether or not the public routes above are switched on; every
// route asks for manage_options.
new AIREWP_Admin_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_migrate_robots_policy' ], 4 );
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' ] );
// Re-measure the discovery URLs after the two events that can change
// whether they answer: a settings save and the daily rebuild. Content
// saves deliberately do not, they rebuild the documents but cannot
// change whether the web server routes the URL to WordPress, and a
// busy editorial day would otherwise trigger a round of loopback
// requests per post.
add_action( 'airewp_settings_updated', [ AIREWP_Endpoint_Check::class, 'invalidate' ], 20 );
add_action( 'airewp_cron_regenerate', [ AIREWP_Endpoint_Check::class, 'invalidate' ], 20 );
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' => false, // 4.4.3: every AI crawler is allowed unless switched off
'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,
'seo_force_with_plugin' => false, // 4.4.3: output meta tags even next to Yoast, Rank Math and co.
// 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';
/** Set once the 4.4.3 robots policy migration has run on this site. */
public const ROBOTS_MIGRATED_OPTION = 'airewp_robots_migrated';
/** Set when the migration changed something the site owner should hear about. */
public const ROBOTS_NOTICE_OPTION = 'airewp_robots_notice';
/**
* Records whether the last rebuild reached the disk.
*/
private const WRITE_STATUS_OPTION = 'airewp_write_status';
/**
* 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 );
}
}
/**
* Stop blocking AI crawlers on sites that never chose to.
*
* Up to 4.4.2 the activation hook wrote the defaults into the option, and
* the default blocked thirteen training crawlers, GPTBot and ClaudeBot
* among them. So every existing site carries robots_block_training=true
* whether the owner ever looked at the checkbox or not, and the two cases
* cannot be told apart. This runs once per site, switches the block off,
* and leaves a notice that says so and where to switch it back on.
*
* Deliberately not tied to maybe_upgrade(): that one only records itself
* once llms.txt could be written and keeps retrying otherwise, and a
* policy change must happen exactly once.
*/
public function maybe_migrate_robots_policy(): void {
if ( get_option( self::ROBOTS_MIGRATED_OPTION ) ) {
return;
}
$saved = get_option( AIREWP_OPTION_KEY, [] );
if ( is_array( $saved ) && ! empty( $saved['robots_block_training'] ) ) {
$saved['robots_block_training'] = false;
update_option( AIREWP_OPTION_KEY, $saved );
update_option( self::ROBOTS_NOTICE_OPTION, 1, false );
}
update_option( self::ROBOTS_MIGRATED_OPTION, 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 {
AIREWP_LLMS_Txt::forget();
$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;
// The return value existed and nobody read it, so a site that could not
// write anything looked exactly like a healthy one in the admin. Record
// it. update_option() skips the write when the value is unchanged, so
// this costs a query only when the state actually flips.
update_option( self::WRITE_STATUS_OPTION, $ok ? '1' : '0', true );
do_action( 'airewp_files_regenerated', $settings );
return $ok;
}
/**
* Whether the last rebuild got everything onto disk.
*
* Defaults to true so a site that has not rebuilt since the update does not
* greet its owner with a warning about something that may well be fine.
*/
public static function writes_ok(): bool {
return '0' !== get_option( self::WRITE_STATUS_OPTION, '1' );
}
/**
* What is known about the .well-known directory, for the admin warning.
*
* Everything here is optional and may come back unknown. A transport that
* cannot report ownership, or a host without the posix extension, is common
* enough, and a warning that names the directory alone is still worth
* showing. Note "writable" is nullable: not knowing is a third answer, and
* collapsing it into false would blame this directory for a failure that
* happened somewhere else.
*
* @return array{path: string, exists: bool, writable: ?bool, owner: string, php_user: string}
*/
public static function well_known_diagnostics(): array {
$path = AIREWP_ROOT_PATH . '.well-known/';
$fs = self::filesystem();
$exists = false;
$writable = null;
$owner = '';
if ( null !== $fs ) {
$exists = (bool) $fs->exists( $path );
if ( $exists ) {
$writable = (bool) $fs->is_writable( $path );
// WP_Filesystem_Direct::owner() cannot report root. It reads
// the owner id and returns false on a falsy value, and root's
// id is 0, so the one owner worth naming here is the one it
// never names. Measured, not assumed. Ask it first anyway,
// because a non-direct transport knows things a local
// fileowner() call on a remote path does not.
$reported = $fs->owner( $path );
if ( false !== $reported && '' !== (string) $reported ) {
$owner = (string) $reported;
} else {
$owner = self::owner_name( $path );
}
}
}
$php_user = '';
if ( function_exists( 'posix_geteuid' ) && function_exists( 'posix_getpwuid' ) ) {
$info = @posix_getpwuid( posix_geteuid() );
$php_user = is_array( $info ) ? (string) ( $info['name'] ?? '' ) : '';
}
return [
'path' => $path,
'exists' => $exists,
'writable' => $writable,
'owner' => $owner,
'php_user' => $php_user,
];
}
/**
* Resolve a path's owner to a user name, root included.
*
* @return string Empty when the platform cannot say.
*/
private static function owner_name( string $path ): string {
if ( ! function_exists( 'fileowner' ) ) {
return '';
}
$uid = @fileowner( $path );
// Strict comparison: 0 is root and a perfectly good answer.
if ( false === $uid ) {
return '';
}
if ( ! function_exists( 'posix_getpwuid' ) ) {
return (string) $uid;
}
$info = @posix_getpwuid( (int) $uid );
return is_array( $info ) && ! empty( $info['name'] ) ? (string) $info['name'] : (string) $uid;
}
/**
* 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();
}
/**
* Turn stored post content into readable plain text.
*
* Everything this plugin publishes about a page (llms-full.txt, the meta
* description, the FAQ schema, the keyword analysis) starts as
* $post->post_content, and that is not text. On a site built with a page
* builder it is a shortcode tree, and wp_strip_all_tags() leaves shortcodes
* completely untouched: it removes HTML tags, nothing else. A reader then
* gets [vc_row us_bg_overlay_color="linear-gradient(...)"] where the
* description should be, in the search results and in the social preview.
*
* Why not strip_shortcodes(), the function that exists for this. Measured,
* both ways, and it is wrong both ways:
*
* - When the builder is not loaded (cron, WP-CLI, a rebuild triggered
* from an admin page it does not hook into) nothing is registered and
* the function changes NOTHING. That is the common case here.
* - When the builder IS loaded it deletes the whole enclosing block
* INCLUDING the text inside it, so the description comes back empty.
*
* So the brackets go by pattern, without the shortcode API and without
* running anybody's code. The name must start with a letter, which keeps
* footnote markers like [1] intact. A lone bracketed word such as
* [Hinweis] is removed, and that is the accepted cost: in WordPress a
* bracketed word IS a shortcode candidate, and there is no way to tell
* them apart without knowing every registered tag.
*
* Tags become a SPACE rather than being dropped, or a heading welds itself
* onto the paragraph below it and the reader gets "UeberschriftText".
*/
public static function to_plain_text( string $content ): string {
if ( '' === $content ) {
return '';
}
// First, and before anything else touches a tag: script and style
// blocks go WITH their content. wp_strip_all_tags() does that too, but
// it runs at the END of this chain, and by then the generic tag
// pattern below has already removed the markers
// and left the code between them standing as ordinary text. A tracking
// snippet or a builder's inline CSS then reads as prose and lands in
// llms-full.txt, in the meta description and in every social preview.
$text = self::strip_code_blocks( $content );
// Page builders store raw blocks as base64(rawurlencode(...)) INSIDE
// the shortcode, so removing the shortcode leaves the payload standing
// as a wall of letters. Unpacking it first means whatever is inside
// goes through the same shortcode and tag stripping as the rest.
$text = self::decode_builder_payloads( $text );
$stripped = preg_replace( '/\[\/?[a-zA-Z][a-zA-Z0-9_-]*(?:\s[^\]]*)?\]/', ' ', $text );
if ( null !== $stripped ) {
// A catastrophic backtrack returns null. Carry on with what we
// have rather than turning the whole description into nothing.
$text = $stripped;
}
$spaced = preg_replace( '/<[^>]*>/', ' ', $text );
if ( null !== $spaced ) {
$text = $spaced;
}
// Still worth running: the pattern above cannot see a malformed tag
// that never closes.
$text = wp_strip_all_tags( $text );
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$collapsed = preg_replace( '/\s+/', ' ', $text );
return trim( null === $collapsed ? $text : $collapsed );
}
/**
* Remove script, style and noscript blocks together with their content.
*
* Split out of to_plain_text() so it can run FIRST. wp_strip_all_tags()
* carries the same pattern, but by the time it runs the generic
* <[^>]*> replacement has already eaten the opening and closing markers
* and left the code between them behind as text. Measured on a page with
* an inline tracking snippet: the meta description read
* "var tracker={id:...};function boot()...".
*
* The pattern requires a closing tag, so it does not reach for an
* unclosed one. That is a limit, not a rescue: measured, an unclosed tag
* takes the rest of the text with it anyway, because strip_tags() at the
* end of the chain drops everything from a lone "<" onwards and has
* always done so. Matching greedily here would change nothing about that
* and would only make the failure harder to read.
*/
private static function strip_code_blocks( string $content ): string {
$stripped = preg_replace( '@<(script|style|noscript)\b[^>]*>.*?\1\s*>@is', ' ', $content );
return null === $stripped ? $content : $stripped;
}
/**
* Unpack the base64 payloads page builders leave inside their shortcodes.
*
* WPBakery and the plugins built on it store raw HTML, raw JS and nested
* shortcodes as base64(rawurlencode(...)) between the shortcode tags.
* Stripping the shortcode leaves the payload, so a live site published
* "JTVCc3RtLWNhbGMlMjBpZCUzRCUyMjUxMTU5JTIyJTVE" into llms-full.txt in
* the middle of a sentence about its pricing. That is the same failure as
* the raw shortcode tree one layer down: markup reaching a reader that
* only ever wanted the text.
*
* The test for "is this a payload" is deliberately narrow. A decoded
* payload may only contain the characters an encoder leaves alone, plus
* %XX escapes, and must carry at least one escape. The character set is
* the UNION of both encoders in play, because the builder uses whichever
* side produced the block: rawurlencode() in PHP keeps A-Z a-z 0-9 - _ . ~
* and encodeURIComponent() in the browser keeps ! * ' ( ) on top of that.
* Leaving those five out cost a real detection in testing. A hash, an API
* key, a data: URI or a long German compound decodes to something outside
* the set and is handed back untouched.
*/
private static function decode_builder_payloads( string $content ): string {
$decoded = preg_replace_callback(
'/[A-Za-z0-9+\/]{16,}={0,2}/',
static function ( array $match ): string {
// base64_decode: the only call in the plugin. It unpacks a
// page builder's payload so it is NOT published as a wall of
// letters; the result is text on its way out, it is never
// executed, included or unserialised, and strict mode rejects
// anything that is not clean base64. The scanner in the test
// suite grants this one call site by name and fails on a
// second one.
$payload = base64_decode( $match[0], true );
if ( false === $payload || '' === $payload ) {
return $match[0];
}
if ( ! preg_match( '/\A[A-Za-z0-9\-_.~!*\'()+]*(?:%[0-9A-Fa-f]{2}[A-Za-z0-9\-_.~!*\'()+]*)+\z/', $payload ) ) {
return $match[0];
}
$text = rawurldecode( $payload );
if ( ! mb_check_encoding( $text, 'UTF-8' ) ) {
return $match[0];
}
// The payload can carry its own script block, so it gets the
// same treatment the outer document just had. Spaces around
// it, or the unpacked text welds itself onto its neighbours.
return ' ' . self::strip_code_blocks( $text ) . ' ';
},
$content
);
return null === $decoded ? $content : $decoded;
}
/**
* 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();