'specific','llms'=>0,'title'=>get_bloginfo('name'),'summary'=>'','context'=>'','section_order'=>'','refresh_mode'=>'manual','logging'=>0,'retention'=>30]; } public static function settings(): array { $stored = get_option('zire_ai_settings', []); $stored = is_array($stored) ? $stored : []; $settings = wp_parse_args($stored, self::defaults()); // Preserve the earlier background-draft preference when upgrading. if (!array_key_exists('refresh_mode', $stored) && !empty($stored['autorefresh'])) { $settings['refresh_mode'] = 'draft'; } if (!in_array($settings['refresh_mode'], ['manual','draft','publish'], true)) { $settings['refresh_mode'] = 'manual'; } unset($settings['autorefresh']); return $settings; } public static function exclude_selectors(int $id): array { $value = (string) get_post_meta($id, '_zire_ai_exclude_selectors', true); $selectors = preg_split('/\R/u', $value, -1, PREG_SPLIT_NO_EMPTY); return array_values(array_unique(array_map('trim', $selectors ?: []))); } /** Detect only shortcodes that WordPress has actually registered. */ private static function contains_registered_shortcode(string $content): bool { global $shortcode_tags; if ($content === '' || empty($shortcode_tags) || !is_array($shortcode_tags)) { return false; } foreach (array_keys($shortcode_tags) as $tag) { if (has_shortcode($content, (string) $tag)) { return true; } } return false; } public static function boot(): void { add_action('init', [__CLASS__, 'rewrite_rules']); add_action('init', [__CLASS__, 'maybe_flush_rewrite_rules'], 20); add_filter('query_vars', [__CLASS__, 'query_vars']); add_action('template_redirect', [__CLASS__, 'serve'], -20); add_action('wp_head', [__CLASS__, 'discovery'], 2); add_action('save_post', [__CLASS__, 'invalidate'], 100, 3); add_action('zire_ai_refresh', [__CLASS__, 'refresh'], 10, 2); add_action('admin_init', [__CLASS__, 'privacy_policy_text']); } /** * Register clean Markdown URLs such as: * /about/index.md * /how-to-optimise-content-for-ai-answers/index.md */ public static function rewrite_rules(): void { add_rewrite_rule( '^index\.md/?$', 'index.php?zire_ai_md_path=__front__', 'top' ); add_rewrite_rule( '^(.+?)/index\.md/?$', 'index.php?zire_ai_md_path=$matches[1]', 'top' ); } public static function maybe_flush_rewrite_rules(): void { $installed_version = (string) get_option( 'zire_ai_rewrite_version', '' ); if ($installed_version === self::REWRITE_VERSION) { return; } flush_rewrite_rules(false); update_option( 'zire_ai_rewrite_version', self::REWRITE_VERSION, false ); } /** * Allow WordPress to recognise the Markdown path query variable. */ public static function query_vars(array $vars): array { $vars[] = 'zire_ai_md_path'; return $vars; } /** * Resolve a clean Markdown path back to its WordPress Page or Post. */ private static function markdown_id_from_path(string $path): int { if ($path === '__front__') { return (int) get_option('page_on_front'); } $path = trim(rawurldecode($path), '/'); if ($path === '' || str_contains($path, '..')) { return 0; } return (int) url_to_postid( home_url('/' . $path . '/') ); } public static function eligibility_issues(int $id): array { $issues = []; $p = get_post($id); if (!$p) { return ['Page could not be found.']; } if (!in_array($p->post_type, ['post','page'], true)) { $issues[] = 'Only standard Pages and Posts are supported.'; } if ($p->post_status !== 'publish') { $issues[] = 'The page is not published.'; } if ($p->post_password !== '') { $issues[] = 'The page is password protected.'; } if (!get_option('blog_public')) { $issues[] = 'WordPress search engine visibility is disabled.'; } if (get_post_meta($id, '_zire_ai_exclude', true)) { $issues[] = 'Exclude this page in every mode is selected.'; } $path = (string) wp_parse_url(get_permalink($id), PHP_URL_PATH); if (preg_match('~/(dashboard|portal|account|my-account|login|register|checkout|cart|billing|onboarding|wp-admin)(/|$)~i', $path)) { $issues[] = 'The URL looks like an account, login, checkout or portal page.'; } foreach (['woocommerce_myaccount_page_id','woocommerce_checkout_page_id','woocommerce_cart_page_id','wp_page_for_privacy_policy'] as $option) { if ((int) get_option($option) === $id) { $issues[] = 'WordPress or WooCommerce identifies this as a protected/system page.'; break; } } $rank = (array) get_post_meta($id, 'rank_math_robots', true); if (in_array('noindex', $rank, true) || get_post_meta($id, '_yoast_wpseo_meta-robots-noindex', true) === '1') { $issues[] = 'A known SEO setting marks this page noindex.'; } // Shortcodes can render private or user-specific data. Require a deliberate per-page override. if (self::contains_registered_shortcode((string) $p->post_content) && !get_post_meta($id, '_zire_ai_allow_shortcodes', true)) { $issues[] = 'The page contains a shortcode. Confirm it renders only public content, then enable the shortcode override.'; } return $issues; } public static function eligible(int $id): bool { $p = get_post($id); return $p && (bool) apply_filters('zire_ai_public_eligible', !self::eligibility_issues($id), $p); } public static function enabled(int $id): bool { return self::eligible($id) && (self::settings()['mode'] === 'all' || (bool) get_post_meta($id, '_zire_ai_include', true)); } public static function document(int $id): array { if (!self::enabled($id)) { return []; } $data = get_post_meta($id, self::DATA, true); if (!is_array($data) || empty($data['markdown']) || ($data['modified'] ?? '') !== get_post_field('post_modified_gmt', $id) || ($data['url'] ?? '') !== get_permalink($id) || ($data['exclude_selectors'] ?? []) !== self::exclude_selectors($id)) { return []; } return $data; } public static function md_url(int $id): string { $permalink = get_permalink($id); if (!$permalink) { return ''; } return trailingslashit($permalink) . 'index.md'; } public static function llms_url(): string { return home_url('/llms.txt'); } public static function llms_conflict(): bool { if (file_exists(ABSPATH . 'llms.txt')) { return true; } $modules = get_option('rank_math_modules', []); foreach ((array) $modules as $key => $value) { if ((is_string($value) && preg_match('/llms/i', $value)) || (is_string($key) && preg_match('/llms/i', $key) && $value)) { return true; } } return false; } public static function invalidate(int $id, $post, bool $update): void { if (wp_is_post_revision($id) || wp_is_post_autosave($id)) { return; } $had = get_post_meta($id, self::DATA, true); delete_post_meta($id, self::DATA); delete_post_meta($id, self::DRAFT); foreach (['draft','publish'] as $scheduled_mode) { wp_clear_scheduled_hook('zire_ai_refresh', [$id, $scheduled_mode]); } if (!is_array($had) || empty($had['markdown'])) { return; } $mode = self::settings()['refresh_mode']; if (!self::enabled($id) || $mode === 'manual') { update_post_meta($id, self::REFRESH_STATUS, [ 'status' => 'withdrawn', 'mode' => 'manual', 'time' => gmdate('Y-m-d H:i:s'), 'message' => 'The source changed. Generate and approve a fresh preview.', ]); return; } update_post_meta($id, self::REFRESH_STATUS, [ 'status' => 'pending', 'mode' => $mode, 'time' => gmdate('Y-m-d H:i:s'), 'message' => $mode === 'publish' ? 'Automatic Markdown replacement is queued.' : 'A replacement review draft is queued.', ]); $scheduled = wp_schedule_single_event(time() + 30, 'zire_ai_refresh', [$id, $mode], true); if (is_wp_error($scheduled) || !$scheduled) { update_post_meta($id, self::REFRESH_STATUS, [ 'status' => 'failed', 'mode' => $mode, 'time' => gmdate('Y-m-d H:i:s'), 'message' => is_wp_error($scheduled) ? $scheduled->get_error_message() : 'WordPress could not schedule the background refresh.', ]); } } public static function refresh(int $id, string $mode = 'draft'): void { if (!in_array($mode, ['draft','publish'], true) || self::settings()['refresh_mode'] !== $mode || !self::enabled($id)) { update_post_meta($id, self::REFRESH_STATUS, [ 'status' => 'cancelled', 'mode' => $mode, 'time' => gmdate('Y-m-d H:i:s'), 'message' => 'The queued refresh was cancelled because the page or publishing settings changed.', ]); return; } $result = self::generate($id); if (is_wp_error($result)) { update_post_meta($id, self::REFRESH_STATUS, [ 'status' => 'failed', 'mode' => $mode, 'time' => gmdate('Y-m-d H:i:s'), 'message' => $result->get_error_message(), ]); return; } if ($mode === 'publish') { $published = self::publish($id); update_post_meta($id, self::REFRESH_STATUS, [ 'status' => $published ? 'published' : 'failed', 'mode' => $mode, 'time' => gmdate('Y-m-d H:i:s'), 'message' => $published ? 'Published Markdown was updated automatically.' : 'The replacement was generated but could not be published.', ]); return; } update_post_meta($id, self::REFRESH_STATUS, [ 'status' => 'draft', 'mode' => $mode, 'time' => gmdate('Y-m-d H:i:s'), 'message' => 'A fresh review draft is ready.', ]); } public static function fetch(int $id, string $agent = 'AEOHelper-Preview/0.1'): array { $url = get_permalink($id); if (!$url || wp_parse_url($url, PHP_URL_HOST) !== wp_parse_url(home_url('/'), PHP_URL_HOST)) { throw new RuntimeException('Only same-site public permalinks can be requested.'); } $r = wp_safe_remote_get($url, ['timeout'=>20,'redirection'=>0,'limit_response_size'=>2097153,'cookies'=>[], 'headers'=>['Accept'=>'text/html','Cache-Control'=>'no-cache'], 'user-agent'=>$agent]); if (is_wp_error($r)) { throw new RuntimeException(esc_html($r->get_error_message())); } $code = wp_remote_retrieve_response_code($r); $body = wp_remote_retrieve_body($r); $type = (string) wp_remote_retrieve_header($r, 'content-type'); if ($code !== 200) { throw new RuntimeException(sprintf('Source returned HTTP %d. Redirects are not followed; check the permalink and firewall.', (int) $code)); } if (stripos($type, 'text/html') === false) { throw new RuntimeException(sprintf('Source is not HTML: %s', esc_html($type))); } if (strlen($body) > 2097152) { throw new RuntimeException('Source exceeds the 2 MB safety limit.'); } $robots = strtolower((string) wp_remote_retrieve_header($r, 'x-robots-tag')); if (preg_match('/\b(noindex|none)\b/', $robots)) { throw new RuntimeException('Source has an X-Robots-Tag noindex directive.'); } return ['body'=>$body,'type'=>$type,'code'=>$code,'robots'=>$robots,'url'=>$url]; } public static function generate(int $id) { if (!self::enabled($id)) { return new WP_Error('ineligible', 'Enable this public page first. Private, noindex, password-protected and shortcode pages are excluded.'); } try { $response = self::fetch($id); $selector = (string) get_post_meta($id, '_zire_ai_selector', true); $data = (new Zire_AI_Markdown($response['url']))->extract($response['body'], $selector, self::exclude_selectors($id)); if ($data['canonical'] && untrailingslashit($data['canonical']) !== untrailingslashit($response['url'])) { throw new RuntimeException('The HTML canonical points to another URL. Resolve that before publishing a copy.'); } $data['url'] = $response['url']; $data['modified'] = get_post_field('post_modified_gmt', $id); $data['generated'] = gmdate('c'); $data['html_bytes'] = strlen($response['body']); $data['markdown'] = '\n\n" . $data['markdown'] . "\n"; update_post_meta($id, self::DRAFT, $data); delete_post_meta($id, '_zire_ai_error'); return $data; } catch (Throwable $e) { delete_post_meta($id, self::DRAFT); delete_post_meta($id, self::DATA); update_post_meta($id, '_zire_ai_error', sanitize_text_field($e->getMessage())); return new WP_Error('generation_failed', $e->getMessage()); } } public static function publish(int $id): bool { $draft = get_post_meta($id, self::DRAFT, true); if (!self::enabled($id) || !is_array($draft) || empty($draft['markdown']) || $draft['modified'] !== get_post_field('post_modified_gmt', $id) || $draft['url'] !== get_permalink($id) || ($draft['exclude_selectors'] ?? []) !== self::exclude_selectors($id)) { return false; } update_post_meta($id, self::DATA, $draft); delete_post_meta($id, self::REFRESH_STATUS); return true; } public static function discovery(): void { if (!is_singular(['post', 'page'])) { return; } $id = get_queried_object_id(); /* * Advertise the approved Markdown version. */ if (self::document($id)) { echo '' . "\n"; } /* * Advertise llms.txt whether it is managed by this plugin * or provided as a physical file in the WordPress root. */ $physical_llms_exists = is_readable( ABSPATH . 'llms.txt' ); $plugin_llms_active = self::settings()['llms'] && !self::llms_conflict(); if ( $physical_llms_exists || $plugin_llms_active ) { echo '' . "\n"; } } public static function serve(): void { /* * Redirect legacy query-based Markdown URLs to their clean URL. * This is a public, read-only endpoint. It does not modify settings * or privileged data, so an authentication nonce is not appropriate. */ if (isset($_GET['zire_ai_md'])) { $raw = is_scalar($_GET['zire_ai_md']) ? sanitize_text_field(wp_unslash((string) $_GET['zire_ai_md'])) : ''; $id = ctype_digit($raw) ? (int) $raw : 0; $data = self::document($id); if ($data) { $target = self::md_url($id); if (isset($_GET['zire_ai_download'])) { $target = add_query_arg( 'zire_ai_download', '1', $target ); } $method = strtoupper( sanitize_key( wp_unslash( (string) ( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ) ); if (in_array($method, ['GET', 'HEAD'], true)) { Zire_AI_Activity::log( 'markdown', $id, $method, 301 ); wp_safe_redirect( $target, 301, 'AEOHelper - Markdown Publisher' ); exit; } } self::output( "Markdown is not published for this page.\n", 'text/markdown', 404, '', 'markdown', $id ); } /* * Serve clean Markdown URLs such as /about/index.md. */ $markdown_path = get_query_var( 'zire_ai_md_path', null ); if ( is_string($markdown_path) && $markdown_path !== '' ) { $id = self::markdown_id_from_path( $markdown_path ); $data = self::document($id); if ( $data && isset($_GET['zire_ai_download']) ) { header( 'Content-Disposition: attachment; filename="page-' . $id . '.md"' ); } self::output( $data ? $data['markdown'] : "Markdown is not published for this page.\n", 'text/markdown', $data ? 200 : 404, $data ? (string) get_permalink($id) : '', 'markdown', $id ); } /* * Serve llms.txt only when the plugin owns the endpoint. */ $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REQUEST_URI'])) : ''; $path = wp_parse_url( $request_uri, PHP_URL_PATH ); if ( $path === wp_parse_url( self::llms_url(), PHP_URL_PATH ) && self::settings()['llms'] && !self::llms_conflict() ) { self::output( self::llms_text(), 'text/plain', 200, '', 'llms.txt', 0 ); } } private static function output( string $body, string $type, int $status, string $canonical_url, string $endpoint, int $post_id ): void { if (!defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); } $method = strtoupper( sanitize_key( wp_unslash( (string) ( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ) ); $allowed = in_array( $method, ['GET', 'HEAD'], true ); $status = $allowed ? $status : 405; Zire_AI_Activity::log( $endpoint, $post_id, $method, $status ); status_header($status); nocache_headers(); header( 'Cache-Control: no-store, private, max-age=0', true ); header( 'Content-Type: ' . $type . '; charset=UTF-8' ); header('X-Content-Type-Options: nosniff'); header('X-AEOHelper: ' . ZIRE_AI_VERSION); /* * Successful Markdown documents point back to their HTML * source using an HTTP canonical. */ if ( $status === 200 && $canonical_url !== '' ) { header( 'Link: <' . esc_url_raw($canonical_url) . '>; rel="canonical"', false ); } /* * Only genuine errors are excluded from indexing. */ if ($status >= 400) { header( 'X-Robots-Tag: noindex', true ); } if (!$allowed) { header('Allow: GET, HEAD'); exit; } if ($method !== 'HEAD') { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw Markdown/plain-text endpoint body is intentionally emitted after generation/sanitisation. echo $body; } exit; } private static function manifest_text($value): string { $text = html_entity_decode(wp_strip_all_tags((string) $value), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim((string) preg_replace('/\s+/u', ' ', $text)); } private static function stored_seo_value(int $id, array $keys): string { foreach ($keys as $key) { $value = (string) get_post_meta($id, $key, true); if ($value === '') { continue; } $replacements = [ '%%title%%' => (string) get_the_title($id), '%%sitename%%' => (string) get_bloginfo('name'), '%%sep%%' => '–', '%%excerpt%%' => (string) get_post_field('post_excerpt', $id), '%title%' => (string) get_the_title($id), '%sitename%' => (string) get_bloginfo('name'), '%sep%' => '–', '%excerpt%' => (string) get_post_field('post_excerpt', $id), '%excerpt_only%' => (string) get_post_field('post_excerpt', $id), ]; $value = strtr($value, $replacements); // Do not expose unresolved SEO-template variables in a public manifest. if (preg_match('/%{1,2}[a-z0-9_:-]+%{1,2}/i', $value)) { continue; } $value = self::manifest_text($value); if ($value !== '') { return $value; } } return ''; } private static function manifest_title(int $id, array $document): string { $manual = self::manifest_text(get_post_meta($id, '_zire_ai_title', true)); if ($manual !== '') { return $manual; } $captured = self::manifest_text($document['seo_title'] ?? ''); if ($captured !== '') { return $captured; } $stored = self::stored_seo_value($id, ['rank_math_title', '_yoast_wpseo_title']); if ($stored !== '') { return $stored; } return self::manifest_text(get_the_title($id)); } private static function manifest_description(int $id, array $document): string { $manual = self::manifest_text(get_post_meta($id, '_zire_ai_description', true)); if ($manual !== '') { return $manual; } $captured = self::manifest_text($document['seo_description'] ?? ''); if ($captured !== '') { return $captured; } $stored = self::stored_seo_value($id, ['rank_math_description', '_yoast_wpseo_metadesc']); if ($stored !== '') { return $stored; } return self::manifest_text(get_post_field('post_excerpt', $id)); } public static function llms_text(): string { $s = self::settings(); $out = '# ' . sanitize_text_field($s['title']) . "\n\n"; if ($s['summary']) { $out .= '> ' . sanitize_text_field($s['summary']) . "\n\n"; } if ($s['context']) { $out .= sanitize_textarea_field($s['context']) . "\n\n"; } $section_ranks = []; $section_lines = preg_split('/\R/u', (string) ($s['section_order'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; foreach ($section_lines as $rank => $section_name) { $section_name = self::manifest_text($section_name); if ($section_name !== '' && !isset($section_ranks[$section_name])) { $section_ranks[$section_name] = (int) $rank; } } $groups = []; $group_first_id = []; // Only approved, currently public exports enter the manifest. Paginate to avoid unbounded queries. $page = 1; do { $ids = get_posts(['post_type'=>['page','post'],'post_status'=>'publish','posts_per_page'=>200,'paged'=>$page,'fields'=>'ids','meta_key'=>self::DATA,'orderby'=>'ID','order'=>'ASC']); foreach ($ids as $id) { $id = (int) $id; $document = self::document($id); if (!$document || get_post_meta($id, '_zire_ai_no_manifest', true)) { continue; } $group = self::manifest_text(get_post_meta($id, '_zire_ai_section', true)) ?: 'Pages'; $title = str_replace(['[',']'], '', self::manifest_title($id, $document)); $description = self::manifest_description($id, $document); $title = (string) apply_filters('zire_ai_llms_title', $title, $id, $document); $description = (string) apply_filters('zire_ai_llms_description', $description, $id, $document); $title = str_replace(['[',']'], '', self::manifest_text($title)); $description = self::manifest_text($description); if ($title === '') { continue; } $order_value = get_post_meta($id, '_zire_ai_manifest_order', true); $order = ($order_value === '' || !is_numeric($order_value)) ? PHP_INT_MAX : max(0, (int) $order_value); $groups[$group][] = [ 'id' => $id, 'order' => $order, 'line' => '- [' . $title . '](' . esc_url_raw(self::md_url($id)) . ')' . ($description ? ': ' . $description : ''), ]; if (!isset($group_first_id[$group]) || $id < $group_first_id[$group]) { $group_first_id[$group] = $id; } } $page++; } while (count($ids) === 200); uksort($groups, static function (string $a, string $b) use ($section_ranks, $group_first_id): int { $a_rank = $section_ranks[$a] ?? PHP_INT_MAX; $b_rank = $section_ranks[$b] ?? PHP_INT_MAX; if ($a_rank !== $b_rank) { return $a_rank <=> $b_rank; } return ($group_first_id[$a] ?? PHP_INT_MAX) <=> ($group_first_id[$b] ?? PHP_INT_MAX); }); foreach ($groups as $group => $links) { usort($links, static function (array $a, array $b): int { if ($a['order'] !== $b['order']) { return $a['order'] <=> $b['order']; } return $a['id'] <=> $b['id']; }); $lines = array_map(static fn(array $entry): string => $entry['line'], $links); $out .= '## ' . sanitize_text_field($group) . "\n\n" . implode("\n", $lines) . "\n\n"; } return $out; } public static function privacy_policy_text(): void { if (!function_exists('wp_add_privacy_policy_content')) { return; } $text = '
' . esc_html__('When request logging is enabled, AEOHelper - Markdown Publisher stores the time, requested Markdown or llms.txt endpoint, response status, request method and a general user-agent category in the local WordPress database. It does not store IP addresses, cookies or referrers, and it does not send this activity to any external service. User-agent categories are unverified claims and may be spoofed. Identical requests are collapsed into 15-minute observations, storage is capped at 10,000 records, and records are deleted automatically according to the selected retention period or can be cleared manually by an administrator.', 'aeohelper-markdown-publisher') . '
'; wp_add_privacy_policy_content('AEOHelper - Markdown Publisher', wp_kses_post(wpautop($text, false))); } }