[ __CLASS__, 'sanitize_settings' ], ] ); } public static function sanitize_settings( array $input ): array { $clean = []; // v1.5.2: strict comparison to defend against PHP 7 loose-comparison quirks (0 == 'free' bug) $clean['mode'] = in_array( $input['mode'] ?? '', [ 'free', 'pro' ], true ) ? $input['mode'] : 'free'; $clean['pro_key'] = sanitize_text_field( $input['pro_key'] ?? '' ); $clean['auto_update'] = ! empty( $input['auto_update'] ); $clean['include_products'] = ! empty( $input['include_products'] ); $clean['include_posts'] = ! empty( $input['include_posts'] ); $clean['include_pages'] = ! empty( $input['include_pages'] ); $clean['max_products'] = min( 500, max( 5, (int) ( $input['max_products'] ?? 50 ) ) ); $clean['max_posts'] = min( 100, max( 5, (int) ( $input['max_posts'] ?? 20 ) ) ); $clean['custom_sections'] = sanitize_textarea_field( $input['custom_sections'] ?? '' ); $clean['webhook_secret'] = sanitize_text_field( $input['webhook_secret'] ?? '' ); $clean['exclude_urls'] = sanitize_textarea_field( $input['exclude_urls'] ?? '' ); $clean['respect_robots_txt'] = ! empty( $input['respect_robots_txt'] ); // v1.5.2 CRITICAL BUGFIX: WP 4.7+ calls this sanitize_callback also when update_option() // is invoked directly from ajax_save_key. The previous code read pro_* values from // get_option() (= the OLD DB state BEFORE this update), which wiped the freshly-set // tier metadata and left pro_verified_at empty \u2014 the plugin then sent a stale/empty // pro_key on Generate and the API returned "Pro or Agency licence required". // Fix: accept pro_* from $input first (= the values ajax_save_key just wrote), // fall back to $existing only when $input is missing them (= normal Settings-form save). $existing = get_option( 'llmstxt_settings', [] ); $clean['pro_plan'] = sanitize_text_field( $input['pro_plan'] ?? $existing['pro_plan'] ?? '' ); $clean['pro_tier'] = in_array( $input['pro_tier'] ?? '', [ 'solo', 'pro', 'business', 'agency' ], true ) ? $input['pro_tier'] : ( $existing['pro_tier'] ?? '' ); $clean['pro_max_urls'] = (int) ( $input['pro_max_urls'] ?? $existing['pro_max_urls'] ?? 0 ); $clean['pro_verified_at'] = sanitize_text_field( $input['pro_verified_at'] ?? $existing['pro_verified_at'] ?? '' ); $clean['pro_email_mask'] = sanitize_text_field( $input['pro_email_mask'] ?? $existing['pro_email_mask'] ?? '' ); return $clean; } public static function enqueue( string $hook ): void { if ( ! str_contains( $hook, 'aiready-llms-txt-generator' ) ) return; wp_enqueue_style( 'llmstxt-admin', LLMSTXT_URL . 'admin/admin.css', [], LLMSTXT_VERSION ); wp_enqueue_style( 'llmstxt-vscore', LLMSTXT_URL . 'admin/visibility-score.css', [ 'llmstxt-admin' ], LLMSTXT_VERSION ); wp_enqueue_script( 'llmstxt-admin', LLMSTXT_URL . 'admin/admin.js', [ 'jquery' ], LLMSTXT_VERSION, true ); wp_enqueue_script( 'llmstxt-vscore', LLMSTXT_URL . 'admin/visibility-score.js', [ 'jquery', 'llmstxt-admin' ], LLMSTXT_VERSION, true ); // Score detail page assets (NEW in 1.5.0) — load only on the detail page if ( str_contains( $hook, 'aiready-llms-txt-generator-score' ) ) { wp_enqueue_style( 'llmstxt-score-detail', LLMSTXT_URL . 'admin/score-detail.css', [ 'llmstxt-admin', 'llmstxt-vscore' ], LLMSTXT_VERSION ); wp_enqueue_script( 'llmstxt-score-detail', LLMSTXT_URL . 'admin/score-detail.js', [ 'jquery', 'llmstxt-admin' ], LLMSTXT_VERSION, true ); } wp_localize_script( 'llmstxt-admin', 'llmstxtAdmin', [ 'nonce' => wp_create_nonce( 'llmstxt_nonce' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'strings' => [ 'generateBtn' => __( '⚡ Generate Now', 'aiready-llms-txt-generator' ), 'generating' => __( 'Generating…', 'aiready-llms-txt-generator' ), 'previewBtn' => __( '👁 Preview', 'aiready-llms-txt-generator' ), 'previewLoading' => __( 'Loading…', 'aiready-llms-txt-generator' ), 'verifyBtn' => __( 'Verify', 'aiready-llms-txt-generator' ), 'verifying' => __( 'Verifying…', 'aiready-llms-txt-generator' ), 'success' => __( 'Done!', 'aiready-llms-txt-generator' ), 'error' => __( 'Error', 'aiready-llms-txt-generator' ), 'networkError' => __( 'Network error', 'aiready-llms-txt-generator' ), 'isLive' => __( 'llms.txt is live', 'aiready-llms-txt-generator' ), 'lastUpdated' => __( 'Last updated:', 'aiready-llms-txt-generator' ), 'lines' => __( 'lines', 'aiready-llms-txt-generator' ), 'keyValid' => __( '✅ Pro key valid', 'aiready-llms-txt-generator' ), 'keyInvalid' => __( '❌ Invalid key', 'aiready-llms-txt-generator' ), 'enterKey' => __( 'Please enter a key first.', 'aiready-llms-txt-generator' ), 'tierSolo' => __( 'Pro Solo', 'aiready-llms-txt-generator' ), 'tierBusiness' => __( 'Pro Business', 'aiready-llms-txt-generator' ), 'tierAgency' => __( 'Agency', 'aiready-llms-txt-generator' ), 'urlLimit' => __( 'URL limit', 'aiready-llms-txt-generator' ), 'saving' => __( 'Saving…', 'aiready-llms-txt-generator' ), // v1.5.0 detail-page strings 'recrawling' => __( 'Re-crawling…', 'aiready-llms-txt-generator' ), 'recrawlReady' => __( 'Re-Crawl now', 'aiready-llms-txt-generator' ), /* translators: %s: number of minutes until re-crawl is available */ 'cooldownLeft' => __( 'Available in %s min', 'aiready-llms-txt-generator' ), 'copied' => __( 'Copied!', 'aiready-llms-txt-generator' ), 'exporting' => __( 'Preparing export…', 'aiready-llms-txt-generator' ), // v1.6.0 loading-screen step messages (cycled in score-detail.js while AJAX runs) 'loadingSteps' => [ __( 'Checking Schema.org markup on sample products…', 'aiready-llms-txt-generator' ), __( 'Reading llms.txt and llms-full.txt…', 'aiready-llms-txt-generator' ), __( 'Probing AI crawler access (GPTBot, Claude, Perplexity)…','aiready-llms-txt-generator' ), __( 'Auditing content quality (Pro)…', 'aiready-llms-txt-generator' ), __( 'Compiling noindex compliance report…', 'aiready-llms-txt-generator' ), __( 'Almost done…', 'aiready-llms-txt-generator' ), ], ], ] ); } public static function render_page(): void { // v1.5.1: Cleanup stale 'llmstxt_last_error' option from previous versions. // The plugin never sets this option — but old installs may have it lingering. if ( get_option( 'llmstxt_last_error', null ) !== null ) { delete_option( 'llmstxt_last_error' ); } $opts = get_option( 'llmstxt_settings', [] ); $file_info = get_option( 'llmstxt_file_info', [] ); $last_err = ''; // Kept for template compat; never populated since v1.5.1 $mode = $opts['mode'] ?? 'free'; $site_url = home_url( '/llms.txt' ); include LLMSTXT_DIR . 'admin/templates/page-main.php'; } /** * Render the AI Visibility Score detail page (NEW in 1.5.0). * * v1.5.5: When the details cache is empty/stale, render a loading skeleton * instead of blocking the page render for many seconds. The skeleton triggers * an AJAX call (ajax_refresh_score_details), then reloads the page once data * is ready. Users now always see immediate visual feedback. */ public static function render_score_page(): void { $opts = get_option( 'llmstxt_settings', [] ); $mode = $opts['mode'] ?? 'free'; $main_page_url = admin_url( 'admin.php?page=aiready-llms-txt-generator' ); // v1.5.5: Cache-aware rendering. Render details synchronously ONLY when // we have a fresh cached entry; otherwise show the loading skeleton and let // JS fetch the data asynchronously. compute_details() makes up to 10 HTTP // round-trips to the local site (Schema sampling) plus one Pro-API crawl, // which can take 10-60 seconds on the first run. $cached = get_option( 'llmstxt_visibility_score_details' ); $cache_ttl = 15 * MINUTE_IN_SECONDS; $has_fresh_cache = ( is_array( $cached ) && isset( $cached['computed_at'] ) && ( time() - (int) $cached['computed_at'] ) < $cache_ttl ); if ( ! $has_fresh_cache ) { // Cache miss/stale: render loading skeleton. JS will fire the AJAX call. include LLMSTXT_DIR . 'admin/templates/score-detail-loading.php'; return; } // Cache hit: render full page synchronously (fast — pure data formatting). $details = LLMSTXT_Visibility_Score::get_details(); $history = LLMSTXT_Visibility_Score::get_history(); $recrawl = LLMSTXT_Visibility_Score::can_recrawl(); $focus = sanitize_key( $_GET['focus'] ?? '' ); $export_json_url = wp_nonce_url( admin_url( 'admin.php?page=aiready-llms-txt-generator-score&action=export&format=json' ), 'llmstxt_score_export' ); $export_csv_url = wp_nonce_url( admin_url( 'admin.php?page=aiready-llms-txt-generator-score&action=export&format=csv' ), 'llmstxt_score_export' ); include LLMSTXT_DIR . 'admin/templates/score-detail-page.php'; } public static function ajax_generate(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden', 403 ); $generator = new LLMSTXT_Generator(); $ok = $generator->generate_and_save(); $err = get_option( 'llmstxt_last_error', '' ); if ( $ok ) { $info = get_option( 'llmstxt_file_info', [] ); wp_send_json_success( [ 'message' => __( 'llms.txt generated successfully.', 'aiready-llms-txt-generator' ), 'updated' => $info['updated'] ?? '', 'lines' => $info['lines'] ?? 0, 'size' => $info['size'] ?? 0, ] ); } else { wp_send_json_error( $err ?: __( 'Generation failed.', 'aiready-llms-txt-generator' ) ); } } public static function ajax_verify_key(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden', 403 ); $key = sanitize_text_field( wp_unslash( $_POST['key'] ?? '' ) ); if ( ! $key ) wp_send_json_error( __( 'No key provided.', 'aiready-llms-txt-generator' ) ); $client = new LLMSTXT_API_Client( $key ); $result = $client->verify_key(); if ( is_wp_error( $result ) ) { wp_send_json_error( $result->get_error_message() ); } if ( empty( $result['valid'] ) ) { wp_send_json_error( $result['error'] ?? __( 'Invalid key.', 'aiready-llms-txt-generator' ) ); } wp_send_json_success( $result ); } public static function ajax_preview(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden', 403 ); $content = ( new LLMSTXT_Generator() )->build_content( 'standard' ); wp_send_json_success( [ 'content' => $content ] ); } public static function ajax_dismiss_notice(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); update_option( 'llmstxt_notice_dismissed', true ); wp_send_json_success(); } /** * v1.7.2: state of the one-time review request. 'later' => shown again after 30 days, * 'done' / 'never' => never shown again. Never nags: only on the plugin's own pages, * only after the first successful generation is at least 7 days old. */ public static function ajax_review_notice(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error(); $state = sanitize_key( wp_unslash( $_POST['state'] ?? 'later' ) ); if ( ! in_array( $state, [ 'later', 'done', 'never' ], true ) ) $state = 'later'; update_option( 'llmstxt_review_notice', [ 'state' => $state, 'at' => time() ], false ); wp_send_json_success(); } public static function maybe_show_review_notice(): void { $screen = get_current_screen(); if ( ! $screen || ! str_contains( $screen->id, 'aiready-llms-txt-generator' ) ) return; // only on our own pages if ( ! current_user_can( 'manage_options' ) ) return; $first = (int) get_option( 'llmstxt_first_generated', 0 ); if ( ! $first || ( time() - $first ) < 7 * DAY_IN_SECONDS ) return; // at least 7 days of real use $st = get_option( 'llmstxt_review_notice', [] ); if ( is_array( $st ) ) { if ( in_array( $st['state'] ?? '', [ 'done', 'never' ], true ) ) return; if ( ( $st['state'] ?? '' ) === 'later' && ( time() - (int) ( $st['at'] ?? 0 ) ) < 30 * DAY_IN_SECONDS ) return; } $review_url = 'https://wordpress.org/support/plugin/aiready-llms-txt-generator/reviews/#new-post'; echo '
'; echo '

' . esc_html__( 'Is the LLMs.txt Generator working well for you?', 'aiready-llms-txt-generator' ) . ' '; echo esc_html__( 'An honest review on WordPress.org helps other site owners decide – and helps us improve. Criticism is welcome too.', 'aiready-llms-txt-generator' ) . '

'; echo '

'; echo '' . esc_html__( '★ Rate the plugin', 'aiready-llms-txt-generator' ) . ' '; echo ' '; echo ''; echo '

'; } public static function plugin_links( array $links ): array { array_unshift( $links, '' . __( 'Settings', 'aiready-llms-txt-generator' ) . '' ); return $links; } public static function maybe_show_notice(): void { $screen = get_current_screen(); if ( $screen && str_contains( $screen->id, 'llmstxt' ) ) return; if ( LLMSTXT_File_Writer::exists() ) return; if ( get_option( 'llmstxt_notice_dismissed' ) ) return; echo '

'; printf( // translators: %s: link to generate page esc_html__( '🤖 LLMs.txt Generator: Your llms.txt hasn\'t been generated yet. %s', 'aiready-llms-txt-generator' ), '' . esc_html__( 'Generate now →', 'aiready-llms-txt-generator' ) . '' ); echo '

'; } public static function ajax_save_key(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden', 403 ); $key = sanitize_text_field( wp_unslash( $_POST['key'] ?? '' ) ); $plan = sanitize_text_field( wp_unslash( $_POST['plan'] ?? '' ) ); $tier = sanitize_text_field( wp_unslash( $_POST['tier'] ?? '' ) ); $max_urls = (int) ( $_POST['max_urls'] ?? 0 ); $email = sanitize_text_field( wp_unslash( $_POST['email'] ?? '' ) ); if ( ! $key ) wp_send_json_error( 'No key.' ); // Whitelist tier values (defends against injection — the API is source of truth, but never trust raw POST) // v1.5.2: include 'business' — the license API returns 'business' for Pro Business plans, // which was previously silently downgraded to 'pro' causing UI inconsistencies. $tier = in_array( $tier, [ 'solo', 'pro', 'business', 'agency' ], true ) ? $tier : 'pro'; $opts = get_option( 'llmstxt_settings', [] ); $opts['mode'] = 'pro'; $opts['pro_key'] = $key; $opts['pro_plan'] = $plan; $opts['pro_tier'] = $tier; $opts['pro_max_urls'] = $max_urls; $opts['pro_email_mask'] = $email; $opts['pro_verified_at'] = current_time( 'mysql' ); update_option( 'llmstxt_settings', $opts ); wp_send_json_success( [ 'plan' => $plan, 'tier' => $tier, 'max_urls' => $max_urls, ] ); } /** * AJAX: Refresh visibility score (force recompute). * * @since 1.4.0 */ public static function ajax_refresh_score(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Permission denied.', 'aiready-llms-txt-generator' ) ], 403 ); } $score = LLMSTXT_Visibility_Score::get( true ); ob_start(); include LLMSTXT_DIR . 'admin/templates/visibility-score-card.php'; $html = ob_get_clean(); wp_send_json_success( [ 'html' => $html, 'score' => $score ] ); } /** * AJAX: Refresh score detail page (force recompute, return JSON). * * @since 1.5.0 */ public static function ajax_refresh_score_details(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Permission denied.', 'aiready-llms-txt-generator' ) ], 403 ); } $details = LLMSTXT_Visibility_Score::get_details( true ); wp_send_json_success( $details ); } /** * AJAX: Force re-crawl (Pro only, with 30 min cooldown). * * @since 1.5.0 */ public static function ajax_force_recrawl(): void { check_ajax_referer( 'llmstxt_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Permission denied.', 'aiready-llms-txt-generator' ) ], 403 ); } $opts = get_option( 'llmstxt_settings', [] ); if ( ( $opts['mode'] ?? 'free' ) !== 'pro' || empty( $opts['pro_key'] ) ) { wp_send_json_error( [ 'message' => __( 'Re-Crawl is a Pro feature.', 'aiready-llms-txt-generator' ) ], 403 ); } $status = LLMSTXT_Visibility_Score::can_recrawl(); if ( ! $status['can_recrawl'] ) { wp_send_json_error( [ 'message' => __( 'Cooldown active. Please wait before re-crawling again.', 'aiready-llms-txt-generator' ), 'seconds_remaining' => $status['seconds_remaining'], ], 429 ); } LLMSTXT_Visibility_Score::record_recrawl(); $details = LLMSTXT_Visibility_Score::get_details( true ); wp_send_json_success( $details ); } /** * Handle export downloads (JSON or ZIP-of-CSVs). * Hooked to admin_init so we can stream the response before any output. * * @since 1.5.0 */ public static function handle_export(): void { if ( empty( $_GET['page'] ) || $_GET['page'] !== 'aiready-llms-txt-generator-score' ) return; if ( empty( $_GET['action'] ) || $_GET['action'] !== 'export' ) return; if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden', 'Forbidden', [ 'response' => 403 ] ); check_admin_referer( 'llmstxt_score_export' ); // Clean output buffers + remove default WP headers (text/html etc.) // WP-Admin's admin.php may have already set Content-Type: text/html. while ( ob_get_level() > 0 ) { ob_end_clean(); } if ( ! headers_sent() ) { header_remove(); header( 'X-Frame-Options: SAMEORIGIN' ); header( 'Referrer-Policy: strict-origin-when-cross-origin' ); status_header( 200 ); } $format = sanitize_key( $_GET['format'] ?? 'json' ); $details = LLMSTXT_Visibility_Score::get_details(); $stamp = current_time( 'Y-m-d_His' ); if ( 'csv' === $format ) { self::stream_csv_zip( $details, $stamp ); } else { self::stream_json( $details, $stamp ); } exit; } /** * Stream a JSON file with full details payload. * * @since 1.5.0 */ private static function stream_json( array $details, string $stamp ): void { $filename = "visibility-score_{$stamp}.json"; header( 'Content-Type: application/json; charset=UTF-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Cache-Control: no-store, no-cache, must-revalidate' ); echo wp_json_encode( $details, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } /** * Stream a ZIP archive containing 5 separate CSVs (one per sub-score) * plus a top-level summary.csv. * * @since 1.5.0 */ private static function stream_csv_zip( array $details, string $stamp ): void { if ( ! class_exists( 'ZipArchive' ) ) { wp_die( esc_html__( 'PHP ZipArchive extension is not available. Please use JSON export instead.', 'aiready-llms-txt-generator' ), 'ZipArchive missing', [ 'response' => 500 ] ); } $tmp_zip = wp_tempnam( "vscore_{$stamp}.zip" ); $zip = new ZipArchive(); if ( $zip->open( $tmp_zip, ZipArchive::CREATE | ZipArchive::OVERWRITE ) !== true ) { wp_die( esc_html__( 'Could not create ZIP archive.', 'aiready-llms-txt-generator' ), '', [ 'response' => 500 ] ); } // 1. summary.csv $sub_scores = $details['subScores'] ?? []; $rows = [ [ 'sub_score', 'score', 'weight_percent', 'note' ] ]; $weights = [ 'schemaOrg' => 30, 'llmsTxtHealth' => 25, 'crawlerAccess' => 15, 'contentQuality' => 15, 'noindexCompliance' => 15, ]; foreach ( $weights as $k => $w ) { $score = $sub_scores[ $k ]['score'] ?? null; $note = $sub_scores[ $k ]['note'] ?? ''; $rows[] = [ $k, $score === null ? 'n/a' : (string) $score, (string) $w, $note ]; } $rows[] = []; $rows[] = [ 'total', (string) ( $details['total'] ?? 0 ), '100', $details['tier'] ?? '' ]; $zip->addFromString( 'summary.csv', self::array_to_csv( $rows ) ); $det = $details['details'] ?? []; // 2. schema-org.csv $rows = [ [ 'product_id', 'name', 'permalink', 'has_type', 'has_price', 'has_offers', 'is_complete' ] ]; foreach ( (array) ( $det['schemaOrg'] ?? [] ) as $r ) { $rows[] = [ (string) ( $r['id'] ?? '' ), (string) ( $r['name'] ?? '' ), (string) ( $r['permalink']?? '' ), ( ! empty( $r['has_type'] ) ) ? '1' : '0', ( ! empty( $r['has_price'] ) ) ? '1' : '0', ( ! empty( $r['has_offers'] ) ) ? '1' : '0', ( ! empty( $r['is_complete'] ) ) ? '1' : '0', ]; } $zip->addFromString( 'schema-org.csv', self::array_to_csv( $rows ) ); // 3. llms-txt.csv $llms = $det['llmsTxtHealth'] ?? []; $rows = [ [ 'check', 'value' ] ]; foreach ( [ 'exists', 'hasTitle', 'hasSections', 'hasEnoughPages', 'pageCount', 'fresh', 'ageDays', 'hasFullVersion' ] as $check ) { $val = $llms[ $check ] ?? null; if ( is_bool( $val ) ) $val = $val ? '1' : '0'; $rows[] = [ $check, (string) ( $val ?? '' ) ]; } $zip->addFromString( 'llms-txt.csv', self::array_to_csv( $rows ) ); // 4. crawler-access.csv $cr = $det['crawlerAccess'] ?? []; $rows = [ [ 'bot', 'allowed' ] ]; foreach ( (array) ( $cr['bots'] ?? [] ) as $bot => $allowed ) { $rows[] = [ $bot, $allowed ? '1' : '0' ]; } $rows[] = []; $rows[] = [ 'has_llms_allow', ! empty( $cr['hasLlmsAllow'] ) ? '1' : '0' ]; $zip->addFromString( 'crawler-access.csv', self::array_to_csv( $rows ) ); // 5. content-quality.csv (Pro) $cq = $det['contentQuality'] ?? null; $rows = [ [ 'page', 'title_length', 'meta_length', 'has_h1', 'alt_tags_ratio', 'has_og_tags' ] ]; if ( is_array( $cq ) && ! empty( $cq['pages'] ) ) { foreach ( $cq['pages'] as $p ) { $rows[] = [ (string) ( $p['url'] ?? '' ), (string) ( $p['title_length'] ?? '' ), (string) ( $p['meta_length'] ?? '' ), ( ! empty( $p['has_h1'] ) ) ? '1' : '0', (string) ( $p['alt_tags_ratio'] ?? '' ), ( ! empty( $p['has_og_tags'] ) ) ? '1' : '0', ]; } } else { $rows[] = [ 'Content Quality is a Pro feature — no data available.', '', '', '', '', '' ]; } $zip->addFromString( 'content-quality.csv', self::array_to_csv( $rows ) ); // 6. noindex-compliance.csv $rows = [ [ 'page_id', 'name', 'permalink', 'source', 'in_llms_txt' ] ]; foreach ( (array) ( $det['noindexCompliance'] ?? [] ) as $r ) { $rows[] = [ (string) ( $r['id'] ?? '' ), (string) ( $r['name'] ?? '' ), (string) ( $r['permalink'] ?? '' ), (string) ( $r['source'] ?? '' ), ( ! empty( $r['in_llms_txt'] ) ) ? '1' : '0', ]; } $zip->addFromString( 'noindex-compliance.csv', self::array_to_csv( $rows ) ); $zip->close(); $filename = "visibility-score_{$stamp}.zip"; header( 'Content-Type: application/zip' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Content-Length: ' . filesize( $tmp_zip ) ); header( 'Cache-Control: no-store, no-cache, must-revalidate' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streaming ZIP download to browser; no WP_Filesystem equivalent for output. readfile( $tmp_zip ); wp_delete_file( $tmp_zip ); } /** * Convert a 2D array to CSV string (RFC 4180). * * @since 1.5.0 */ private static function array_to_csv( array $rows ): string { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://temp is an in-memory PHP stream, not a real filesystem operation. $fh = fopen( 'php://temp', 'r+' ); foreach ( $rows as $row ) { fputcsv( $fh, $row, ',', '"', '\\' ); } rewind( $fh ); $csv = stream_get_contents( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the in-memory PHP stream opened above. fclose( $fh ); return (string) $csv; } }