diff --git a/bavarian-rank-engine/includes/Features/GeoBlock.php b/bavarian-rank-engine/includes/Features/GeoBlock.php new file mode 100644 index 0000000..cf3e315 --- /dev/null +++ b/bavarian-rank-engine/includes/Features/GeoBlock.php @@ -0,0 +1,489 @@ + false, + 'mode' => 'auto_on_publish', + 'post_types' => array( 'post', 'page' ), + 'position' => 'after_first_p', + 'output_style' => 'details_collapsible', + 'title' => 'Quick Overview', + 'label_summary' => 'Summary', + 'label_bullets' => 'Key Points', + 'label_faq' => 'FAQ', + 'minimal_css' => true, + 'custom_css' => '', + 'color_scheme' => 'auto', + 'accent_color' => '', + 'prompt_default' => self::getDefaultPrompt(), + 'word_threshold' => 350, + 'regen_on_update' => false, + 'allow_prompt_addon' => false, + ); + $saved = get_option( self::OPTION_KEY, array() ); + return array_merge( $defaults, is_array( $saved ) ? $saved : array() ); + } + + public static function getDefaultPrompt(): string { + return 'Analyze the following article and create a structured quick overview.' . "\n" + . 'Respond exclusively with a valid JSON object (no Markdown code fences, no text before or after).' . "\n\n" + . 'Language: {language}' . "\n" + . 'Article title: {title}' . "\n\n" + . 'Rules:' . "\n" + . '- summary: 40–90 words, neutral, factual, no advertising, no superlatives.' . "\n" + . '- bullets: 3–7 short key points. No repetition from the summary.' . "\n" + . '- faq: 0–5 question-answer pairs, ONLY if the article genuinely answers questions. Otherwise empty array [].' . "\n" + . '- Do not invent anything. No keyword stuffing. Short, clear sentences.' . "\n" + . '- No phrases like "In this article", "ultimate", "game changer".' . "\n\n" + . 'JSON format (exact):' . "\n" + . '{"summary":"...","bullets":["...","..."],"faq":[{"q":"...","a":"..."}]}' . "\n\n" + . 'Article content:' . "\n" + . '{content}'; + } + public function generate( int $post_id, bool $force = false ): bool { + $post = get_post( $post_id ); + if ( ! $post ) { + return false; + } + + $settings = self::getSettings(); + + // Check lock + if ( ! $force && get_post_meta( $post_id, self::META_LOCK, true ) ) { + return false; + } + + $global = SettingsPage::getSettings(); + $provider = ProviderRegistry::instance()->get( $global['provider'] ); + $api_key = $global['api_keys'][ $global['provider'] ] ?? ''; + + if ( ! $provider || empty( $api_key ) ) { + return false; + } + + $model = $global['models'][ $global['provider'] ] ?? array_key_first( $provider->getModels() ); + $content = wp_strip_all_tags( do_shortcode( $post->post_content ) ); + + // Token-limit the content input + $content = TokenEstimator::truncate( $content, 2000 ); + + $word_count = str_word_count( $content ); + $force_no_faq = $word_count < (int) $settings['word_threshold']; + $addon = $settings['allow_prompt_addon'] + ? sanitize_textarea_field( get_post_meta( $post_id, self::META_ADDON, true ) ) + : ''; + $prompt = $this->buildPrompt( $post, $content, $settings, $addon, $force_no_faq ); + + try { + $raw = $provider->generateText( $prompt, $api_key, $model, 800 ); + $parsed = $this->parseResponse( $raw ); + if ( null === $parsed ) { + return false; + } + $data = $this->qualityGate( $parsed, $force_no_faq ); + $this->saveMeta( $post_id, $data ); + return true; + } catch ( \Exception $e ) { + if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( '[BRE GEO] Generation failed for post ' . $post_id . ': ' . $e->getMessage() ); + } + return false; + } + } + + private function buildPrompt( \WP_Post $post, string $content, array $settings, string $addon, bool $force_no_faq ): string { + $locale_map = array( + 'de_DE' => 'Deutsch', + 'de_DE_formal' => 'Deutsch', + 'de_AT' => 'Deutsch', + 'de_CH' => 'Deutsch', + 'de_CH_informal' => 'Deutsch', + 'en_US' => 'English', + 'en_GB' => 'English', + 'en_AU' => 'English', + 'en_CA' => 'English', + 'fr_FR' => 'Français', + 'fr_BE' => 'Français', + 'fr_CA' => 'Français', + 'es_ES' => 'Español', + 'es_MX' => 'Español', + 'it_IT' => 'Italiano', + 'nl_NL' => 'Nederlands', + 'nl_NL_formal' => 'Nederlands', + 'pt_PT' => 'Português', + 'pt_BR' => 'Português do Brasil', + 'pl_PL' => 'Polski', + 'ru_RU' => 'Русский', + 'sv_SE' => 'Svenska', + 'da_DK' => 'Dansk', + 'nb_NO' => 'Norsk', + 'fi' => 'Suomi', + 'cs_CZ' => 'Čeština', + 'sk_SK' => 'Slovenčina', + 'hu_HU' => 'Magyar', + 'ro_RO' => 'Română', + 'bg_BG' => 'Български', + 'el' => 'Ελληνικά', + 'hr' => 'Hrvatski', + 'tr_TR' => 'Türkçe', + 'ar' => 'العربية', + 'he_IL' => 'עברית', + 'zh_CN' => '中文(简体)', + 'zh_TW' => '中文(繁體)', + 'ja' => '日本語', + 'ko_KR' => '한국어', + ); + $prefix_map = array( + 'de' => 'Deutsch', 'en' => 'English', 'fr' => 'Français', + 'es' => 'Español', 'it' => 'Italiano', 'nl' => 'Nederlands', + 'pt' => 'Português', 'pl' => 'Polski', 'ru' => 'Русский', + 'sv' => 'Svenska', 'da' => 'Dansk', 'nb' => 'Norsk', + 'no' => 'Norsk', 'fi' => 'Suomi', 'cs' => 'Čeština', + 'tr' => 'Türkçe', 'ja' => '日本語', 'ko' => '한국어', + 'zh' => '中文', 'ar' => 'العربية', 'he' => 'עברית', + 'hu' => 'Magyar', 'ro' => 'Română', 'bg' => 'Български', + 'el' => 'Ελληνικά', 'hr' => 'Hrvatski', + ); + + $locale = get_locale(); + $language = $locale_map[ $locale ] + ?? $prefix_map[ strtolower( substr( $locale, 0, 2 ) ) ] + ?? $locale; + + if ( function_exists( 'pll_get_post_language' ) ) { + $pll_lang = pll_get_post_language( $post->ID, 'name' ); + if ( $pll_lang ) { + $language = $pll_lang; + } + } elseif ( defined( 'ICL_LANGUAGE_CODE' ) ) { + $wpml_code = strtolower( (string) ICL_LANGUAGE_CODE ); + $language = $prefix_map[ $wpml_code ] ?? $language; + } + + $prompt = $settings['prompt_default']; + $prompt = str_replace( '{title}', $post->post_title, $prompt ); + $prompt = str_replace( '{content}', $content, $prompt ); + $prompt = str_replace( '{language}', $language, $prompt ); + + if ( $force_no_faq ) { + $prompt .= "\n\nIMPORTANT: Always set faq to an empty array: []"; + } + if ( ! empty( $addon ) ) { + $prompt .= "\n\nAdditional instruction: " . $addon; + } + + return $prompt; + } + + private function parseResponse( string $raw ): ?array { + // Strip markdown code fences if present + $raw = preg_replace( '/^```(?:json)?\s*/i', '', trim( $raw ) ); + $raw = preg_replace( '/\s*```$/', '', $raw ); + $raw = trim( $raw ); + + $data = json_decode( $raw, true ); + if ( ! is_array( $data ) ) { + return null; + } + + // Some AI providers double-encode unicode in string values + // (e.g. ä becomes the literal 6-char sequence \u00e4). + // Decode those residual sequences so ä/ö/ü store and display correctly. + $data = $this->decodeUnicode( $data ); + + // Require at minimum a summary + if ( empty( $data['summary'] ) || ! is_string( $data['summary'] ) ) { + return null; + } + return $data; + } + + private function decodeUnicode( mixed $val ): mixed { + if ( is_string( $val ) ) { + return preg_replace_callback( + '/\\\\u([0-9a-fA-F]{4})/', + static function ( array $m ): string { + $char = mb_chr( hexdec( $m[1] ), 'UTF-8' ); + return $char !== false ? $char : $m[0]; + }, + $val + ); + } + if ( is_array( $val ) ) { + return array_map( array( $this, 'decodeUnicode' ), $val ); + } + return $val; + } + + private function qualityGate( array $data, bool $force_no_faq ): array { + $summary = trim( $data['summary'] ?? '' ); + $bullets = array_values( array_filter( (array) ( $data['bullets'] ?? array() ), 'is_string' ) ); + $faq = $force_no_faq ? array() : array_values( + array_filter( + (array) ( $data['faq'] ?? array() ), + function ( $item ) { + return is_array( $item ) && ! empty( $item['q'] ) && ! empty( $item['a'] ); + } + ) + ); + + // Hard bounds: trim summary if too long + $word_count = str_word_count( $summary ); + if ( $word_count > 140 ) { + $words = explode( ' ', $summary ); + $summary = implode( ' ', array_slice( $words, 0, 140 ) ); + } + + // Trim bullets/FAQ to soft max + if ( count( $bullets ) > 7 ) { + $bullets = array_slice( $bullets, 0, 7 ); + } + if ( count( $faq ) > 5 ) { + $faq = array_slice( $faq, 0, 5 ); + } + + return array( + 'summary' => $summary, + 'bullets' => $bullets, + 'faq' => $faq, + ); + } + + public function saveMeta( int $post_id, array $data ): void { + update_post_meta( $post_id, self::META_SUMMARY, sanitize_text_field( $data['summary'] ?? '' ) ); + update_post_meta( + $post_id, + self::META_BULLETS, + wp_json_encode( array_map( 'sanitize_text_field', $data['bullets'] ?? array() ), JSON_UNESCAPED_UNICODE ) + ); + + $faq_clean = array_map( + function ( $item ) { + return array( + 'q' => sanitize_text_field( $item['q'] ?? '' ), + 'a' => sanitize_text_field( $item['a'] ?? '' ), + ); + }, + $data['faq'] ?? array() + ); + update_post_meta( $post_id, self::META_FAQ, wp_json_encode( $faq_clean, JSON_UNESCAPED_UNICODE ) ); + update_post_meta( $post_id, self::META_GENERATED, time() ); + } + + public static function getMeta( int $post_id ): array { + $summary = get_post_meta( $post_id, self::META_SUMMARY, true ) ?: ''; + $bullets = json_decode( get_post_meta( $post_id, self::META_BULLETS, true ) ?: '[]', true ); + $faq = json_decode( get_post_meta( $post_id, self::META_FAQ, true ) ?: '[]', true ); + return array( + 'summary' => is_string( $summary ) ? $summary : '', + 'bullets' => is_array( $bullets ) ? $bullets : array(), + 'faq' => is_array( $faq ) ? $faq : array(), + ); + } + + public function register(): void { + $settings = self::getSettings(); + if ( empty( $settings['enabled'] ) ) { + return; + } + if ( $settings['output_style'] !== 'store_only_no_frontend' ) { + add_filter( 'the_content', array( $this, 'injectBlock' ) ); + } + if ( $settings['minimal_css'] ) { + add_action( 'wp_enqueue_scripts', array( $this, 'enqueueCss' ) ); + } + if ( ! empty( $settings['custom_css'] ) ) { + add_action( 'wp_head', array( $this, 'inlineCustomCss' ) ); + } + // Publish hook + add_action( 'transition_post_status', array( $this, 'onStatusTransition' ), 20, 3 ); + // Update hook + if ( ! empty( $settings['regen_on_update'] ) ) { + add_action( 'save_post', array( $this, 'onSavePost' ), 20, 2 ); + } + } + + public function enqueueCss(): void { + if ( ! is_singular() ) { + return; + } + wp_enqueue_style( 'bre-geo-frontend', BRE_URL . 'assets/geo-frontend.css', array(), BRE_VERSION ); + } + + public function inlineCustomCss(): void { + if ( ! is_singular() ) { + return; + } + $settings = self::getSettings(); + $css = $settings['custom_css'] ?? ''; + if ( empty( $css ) ) { + return; + } + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '' . "\n"; + } + + public function injectBlock( string $content ): string { + if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) { + return $content; + } + $post_id = get_the_ID(); + if ( ! $post_id ) { + return $content; + } + + // Per-post enabled override: '' = follow global, '1' = on, '0' = off + $per_post = get_post_meta( $post_id, self::META_ENABLED, true ); + if ( $per_post === '0' ) { + return $content; + } + + $meta = self::getMeta( $post_id ); + if ( empty( $meta['summary'] ) && empty( $meta['bullets'] ) ) { + return $content; + } + + $block = $this->renderBlock( $meta ); + $settings = self::getSettings(); + + switch ( $settings['position'] ) { + case 'top': + return $block . $content; + case 'bottom': + return $content . $block; + case 'after_first_p': + default: + $parts = preg_split( '/(<\/p>)/i', $content, 2, PREG_SPLIT_DELIM_CAPTURE ); + if ( count( $parts ) >= 3 ) { + return $parts[0] . $parts[1] . $block . $parts[2]; + } + return $block . $content; + } + } + + private function renderBlock( array $meta ): string { + $settings = self::getSettings(); + $style = $settings['output_style']; + + $title = esc_html( $settings['title'] ); + $label_summary = esc_html( $settings['label_summary'] ); + $label_bullets = esc_html( $settings['label_bullets'] ); + $label_faq = esc_html( $settings['label_faq'] ); + + $inner = ''; + + if ( ! empty( $meta['summary'] ) ) { + $inner .= '
' + . '

' . $label_summary . '

' + . '

' . esc_html( $meta['summary'] ) . '

' + . '
'; + } + + if ( ! empty( $meta['bullets'] ) ) { + $items = ''; + foreach ( $meta['bullets'] as $bullet ) { + $items .= '
  • ' . esc_html( $bullet ) . '
  • '; + } + $inner .= '
    ' + . '

    ' . $label_bullets . '

    ' + . '' + . '
    '; + } + + if ( ! empty( $meta['faq'] ) ) { + $pairs = ''; + foreach ( $meta['faq'] as $item ) { + $pairs .= '
    ' . esc_html( $item['q'] ) . '
    ' + . '
    ' . esc_html( $item['a'] ) . '
    '; + } + $inner .= '
    ' + . '

    ' . $label_faq . '

    ' + . '
    ' . $pairs . '
    ' + . '
    '; + } + + $open_attr = ( $style === 'open_always' ) ? ' open' : ''; + $scheme = $settings['color_scheme'] ?? 'auto'; + $scheme_attr = ( $scheme !== 'auto' ) ? ' data-bre-scheme="' . esc_attr( $scheme ) . '"' : ''; + + $accent = $settings['accent_color'] ?? ''; + $style_attr = ''; + if ( $accent && preg_match( '/^#[0-9a-fA-F]{3,6}$/', $accent ) ) { + $style_attr = ' style="--bre-accent:' . esc_attr( $accent ) . ';"'; + } + + return '
    ' + . '' . $title . '' + . $inner + . '
    '; + } + + public function onStatusTransition( string $new_status, string $old_status, \WP_Post $post ): void { + if ( $new_status !== 'publish' ) { + return; + } + $settings = self::getSettings(); + if ( ! in_array( $post->post_type, $settings['post_types'], true ) ) { + return; + } + $mode = $settings['mode']; + if ( $mode === 'manual_only' ) { + return; + } + if ( $mode === 'hybrid' ) { + $meta = self::getMeta( $post->ID ); + if ( ! empty( $meta['summary'] ) ) { + return; + } + } + $this->generate( $post->ID ); + } + + public function onSavePost( int $post_id, \WP_Post $post ): void { + if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) { + return; + } + if ( $post->post_status !== 'publish' ) { + return; + } + $settings = self::getSettings(); + if ( ! in_array( $post->post_type, $settings['post_types'], true ) ) { + return; + } + $this->generate( $post_id ); + } +} diff --git a/bavarian-rank-engine/includes/Features/LlmsTxt.php b/bavarian-rank-engine/includes/Features/LlmsTxt.php index 1d83cc5..7188c61 100644 --- a/bavarian-rank-engine/includes/Features/LlmsTxt.php +++ b/bavarian-rank-engine/includes/Features/LlmsTxt.php @@ -9,11 +9,14 @@ class LlmsTxt { private const OPTION_KEY = 'bre_llms_settings'; private const CACHE_KEY = 'bre_llms_cache'; + private const NOTICE_META = 'bre_dismissed_llms_rank_math'; + public function register(): void { add_action( 'parse_request', array( $this, 'maybe_serve' ), 1 ); add_action( 'init', array( $this, 'add_rewrite_rule' ) ); add_filter( 'query_vars', array( $this, 'add_query_var' ) ); add_action( 'admin_notices', array( $this, 'rank_math_notice' ) ); + add_action( 'wp_ajax_bre_dismiss_llms_notice', array( $this, 'ajax_dismiss_notice' ) ); } public function maybe_serve(): void { @@ -31,13 +34,30 @@ class LlmsTxt { if ( ! defined( 'RANK_MATH_VERSION' ) ) { return; } + if ( get_user_meta( get_current_user_id(), self::NOTICE_META, true ) ) { + return; + } $settings = self::getSettings(); if ( empty( $settings['enabled'] ) ) { return; } - echo '

    ' - . esc_html__( 'Bavarian Rank Engine bedient llms.txt mit Priorität — kein Handlungsbedarf bei Rank Math.', 'bavarian-rank-engine' ) - . '

    '; + $nonce = wp_create_nonce( 'bre_dismiss_llms_notice' ); + ?> +
    +

    +
    + +