$length_limit ) { $value = ai4seo_trim_string_to_length( $value, $length_limit ); } return $value; } /** * Normalize custom instruction setting values before validation or persistence. * * @param string $setting_name Setting identifier. * @param mixed $setting_value Raw setting value. * @return mixed Normalized setting value. */ function ai4seo_normalize_custom_instructions_setting_value( string $setting_name, $setting_value ) { switch ( $setting_name ) { case AI4SEO_SETTING_GLOBAL_CUSTOM_INSTRUCTIONS: case AI4SEO_SETTING_METADATA_CUSTOM_INSTRUCTIONS: case AI4SEO_SETTING_ATTACHMENT_ATTRIBUTES_CUSTOM_INSTRUCTIONS: // Plain custom-instruction settings share the same text cleanup and subscription cap. return ai4seo_normalize_custom_instructions_value( $setting_value ); case AI4SEO_SETTING_METADATA_POST_TYPE_CUSTOM_INSTRUCTIONS: // Post-type instructions are stored as a sparse map so empty entries do not bloat settings. if ( ! is_array( $setting_value ) ) { return array(); } $normalized_post_type_instructions = array(); $allowed_post_types = array_flip( ai4seo_get_supported_metadata_custom_instructions_post_types() ); foreach ( $setting_value as $post_type => $post_type_custom_instructions ) { $post_type = sanitize_key( $post_type ); // Imported maps may contain stale post types; only keep scopes the plugin can currently render/generate. if ( ! $post_type || ! isset( $allowed_post_types[ $post_type ] ) ) { continue; } $post_type_custom_instructions = ai4seo_normalize_custom_instructions_value( $post_type_custom_instructions ); if ( '' === $post_type_custom_instructions ) { continue; } $normalized_post_type_instructions[ $post_type ] = $post_type_custom_instructions; } return $normalized_post_type_instructions; } return $setting_value; } /** * Validate custom instruction setting values. * * @param string $setting_name Setting identifier. * @param mixed $setting_value Setting value. * @return bool True when valid. */ function ai4seo_validate_custom_instructions_setting_value( string $setting_name, $setting_value ): bool { $length_limit = ai4seo_get_custom_instructions_length_limit(); switch ( $setting_name ) { case AI4SEO_SETTING_GLOBAL_CUSTOM_INSTRUCTIONS: case AI4SEO_SETTING_METADATA_CUSTOM_INSTRUCTIONS: case AI4SEO_SETTING_ATTACHMENT_ATTRIBUTES_CUSTOM_INSTRUCTIONS: // Validate already-normalized text against the same cap used by the renderer and save helpers. return is_string( $setting_value ) && ai4seo_mb_strlen( $setting_value ) <= $length_limit; case AI4SEO_SETTING_METADATA_POST_TYPE_CUSTOM_INSTRUCTIONS: // Validate the sparse post-type map produced by the normalizer before it is saved or imported. if ( ! is_array( $setting_value ) ) { return false; } $allowed_post_types = array_flip( ai4seo_get_supported_metadata_custom_instructions_post_types() ); foreach ( $setting_value as $post_type => $post_type_custom_instructions ) { if ( ! is_string( $post_type ) || ! isset( $allowed_post_types[ $post_type ] ) ) { return false; } if ( ! is_string( $post_type_custom_instructions ) || ai4seo_mb_strlen( $post_type_custom_instructions ) > $length_limit ) { return false; } } return true; } return false; } /** * Return textarea HTML for a custom instruction input. * * @param string $input_id Input id. * @param string $input_name Input name. * @param string $input_value Current value. * @param string $additional_css_classes Additional textarea classes. * @param string $field_label Label used by client-side validation. * @param string $placeholder Optional placeholder text. * @param string $description_id Optional id of visible help text. * @return string Textarea HTML. */ function ai4seo_get_custom_instructions_textarea_tag( string $input_id, string $input_name, string $input_value = '', string $additional_css_classes = '', string $field_label = '', string $placeholder = '', string $description_id = '' ): string { $length_limit = ai4seo_get_custom_instructions_length_limit(); $input_value = ai4seo_normalize_custom_instructions_value( $input_value, $length_limit ); $classes = trim( 'ai4seo-textarea ai4seo-auto-resize-textarea ai4seo-custom-instructions-input ' . $additional_css_classes ); // Expose the PHP-enforced limit to the browser so counters and client-side validation stay in sync. return ''; } /** * Return character counter HTML for a custom instruction textarea. * * @param string $input_id Input id this counter belongs to. * @return string Counter HTML. */ function ai4seo_get_custom_instructions_character_counter_tag( string $input_id ): string { $length_limit = ai4seo_get_custom_instructions_length_limit(); $is_active_subscription_user = ai4seo_user_has_active_subscription(); // Pair the counter with the textarea id so AJAX-rendered forms can initialize counters after insertion. $html = '
'; $html .= ''; $html .= sprintf( /* translators: %1$s: Number of used characters. %2$s: Character limit. */ esc_html__( '%1$s / %2$s characters', 'ai-for-seo' ), ai4seo_format_number_i18n( 0 ), ai4seo_format_number_i18n( $length_limit ) ); $html .= ''; // Free accounts keep the upgrade CTA, while subscribers see why the higher limit is available. if ( ! $is_active_subscription_user ) { $html .= ' · '; $html .= ai4seo_get_subscription_upgrade_prompt_tag( 's', 'custom_instructions_1000_chars' ); } else { $current_plan = ai4seo_get_current_user_plan(); $current_plan_name = ai4seo_get_plan_name( $current_plan ); $current_plan_name_html = '' . esc_html( $current_plan_name ) . ''; $html .= ' · '; $html .= ''; $html .= sprintf( /* translators: %1$s: Character limit. %2$s: Current subscription plan name wrapped in bold markup. */ esc_html__( 'You can enter up to %1$s chars because your %2$s plan is active.', 'ai-for-seo' ), esc_html( ai4seo_format_number_i18n( $length_limit ) ), $current_plan_name_html ); $html .= ''; } $html .= ''; $html .= '
'; return $html; } /** * Return examples and limit support text for a custom instruction context. * * @param string $context The custom instruction context. * @return array{intro: string, examples: string[]} Context details. */ function ai4seo_get_custom_instructions_examples_context_data( string $context ): array { $context = sanitize_key( $context ); $intro = ''; $examples = array(); // Map each UI surface to guidance that matches the scope of instructions collected there. switch ( $context ) { case 'global': $intro = __( 'Use global instructions for site-wide guidance that should affect both metadata and media attributes.', 'ai-for-seo' ); $examples = array( __( 'We sell premium marble kitchen tables for modern homes and interior design projects. Focus on marble patterns, table shapes, room fit, delivery, and long-term maintenance.', 'ai-for-seo' ), __( 'This site is an artist gallery for coastal abstract paintings. Focus on original artworks, collection themes, artist stories, exhibition context, and collectors looking for statement pieces.', 'ai-for-seo' ), __( 'This is a tech blog about practical AI tools, automation, and machine learning trends. Focus on real use cases for founders, developers, and marketing teams.', 'ai-for-seo' ), ); break; case 'metadata': $intro = __( 'Use metadata instructions for titles, descriptions, social previews, and similar metadata fields.', 'ai-for-seo' ); $examples = array( __( 'Always include the city this post is about in the focus keyphrase and meta title.', 'ai-for-seo' ), __( 'Include the article number in the meta description.', 'ai-for-seo' ), __( 'End social media descriptions with this CTA: "Join us now!"', 'ai-for-seo' ), ); break; case 'metadata-post-type': $intro = __( 'Use post type instructions when one content type needs different metadata guidance than the rest of the site.', 'ai-for-seo' ); $examples = array( __( 'Product pages: prioritize use cases, differentiators, and purchase intent.', 'ai-for-seo' ), __( 'Blog posts: write educational metadata and avoid hard-sell language.', 'ai-for-seo' ), __( 'Case studies: mention the industry, result, or project type when available.', 'ai-for-seo' ), ); break; case 'metadata-editor': $intro = __( 'Use entry-specific instructions for guidance that should only affect this entry going forward.', 'ai-for-seo' ); $examples = array( __( 'When the generation language is Auto, output French metadata for this post.', 'ai-for-seo' ), __( 'Mention the product dimensions in the meta description.', 'ai-for-seo' ), __( 'Include "New York" in the focus keyphrase.', 'ai-for-seo' ), __( 'WRITE EVERYTHING IN ALL CAPS!', 'ai-for-seo' ), ); break; case 'media-attributes': $intro = __( 'Use media attribute instructions for image titles, alt text, captions, and media descriptions.', 'ai-for-seo' ); $examples = array( __( 'We sell marble kitchen tables. If not stated otherwise, assume the table material is marble.', 'ai-for-seo' ), __( 'For people\'s names, abbreviate the last name, e.g. "Peter K."', 'ai-for-seo' ), __( 'Product pictures only: format alt text like "product name - dimensions - price" when data is available.', 'ai-for-seo' ), ); break; case 'attachment-attributes-editor': $intro = __( 'Use attachment-specific instructions for guidance that should only affect this media item going forward.', 'ai-for-seo' ); $examples = array( __( 'The table is made of oak timber.', 'ai-for-seo' ), __( 'Do not mention the background color.', 'ai-for-seo' ), __( 'The painting artist is "Peter Francesco". Mention him.', 'ai-for-seo' ), ); break; } if ( ! $examples ) { return array( 'intro' => '', 'examples' => array(), ); } return array( 'intro' => $intro, 'examples' => $examples, ); } /** * Build compact placeholder snippets from 3-4 word examples. * * @param string $context Examples context. * @param int $snippet_word_count Number of words per snippet. * @param int $max_snippets Maximum number of snippets to include. * @return string Placeholder text. */ function ai4seo_get_custom_instructions_placeholder( string $context, int $snippet_word_count = 4, int $max_snippets = 3 ): string { $context = sanitize_key( $context ); // Entry editors use concise starters tailored to the output generated in each modal. if ( 'metadata-editor' === $context ) { return __( 'Include keyword..., Use concise tone..., Mention location..., Avoid clickbait...', 'ai-for-seo' ); } if ( 'attachment-attributes-editor' === $context ) { return __( 'Focus on..., Material is..., Do not mention..., Her name is...', 'ai-for-seo' ); } $context_data = ai4seo_get_custom_instructions_examples_context_data( $context ); if ( empty( $context_data['examples'] ) || ! is_array( $context_data['examples'] ) ) { return ''; } $max_snippets = max( 1, min( 5, $max_snippets ) ); $max_words = max( 3, min( 4, $snippet_word_count ) ); $snippets = array(); foreach ( array_slice( $context_data['examples'], 0, $max_snippets ) as $example ) { $example = trim( str_replace( array( "\r", "\n", "\t" ), ' ', wp_strip_all_tags( (string) $example ) ) ); if ( '' === $example ) { continue; } $example = preg_replace( '/\s+/', ' ', $example ); $words = preg_split( '/\s+/', preg_replace( '/["“”‘’]/u', '', $example ), -1, PREG_SPLIT_NO_EMPTY ); if ( ! is_array( $words ) || ! $words ) { continue; } $snippets[] = implode( ' ', array_slice( $words, 0, $max_words ) ) . '...'; } return implode( ', ', $snippets ); } /** * Return an examples-and-limits tooltip for a custom instruction field. * * @param string $context The custom instruction field context. * @param string $description Optional visible description to include in tooltip. * @param bool $as_icon_trigger If true, render tooltip trigger as a help icon. * @return string Tooltip HTML. */ function ai4seo_get_custom_instructions_examples_tooltip_tag( string $context, string $description = '', bool $as_icon_trigger = false ): string { $context_data = ai4seo_get_custom_instructions_examples_context_data( $context ); $intro = $context_data['intro']; $examples = $context_data['examples']; // Unsupported contexts intentionally render no trigger instead of showing generic or misleading examples. if ( ! $examples ) { return ''; } $tooltip_html = ''; // Keep the contextual introduction optional because every supported context still has useful examples. if ( '' !== $description ) { $tooltip_html .= '' . wpautop( wp_kses_post( $description ) ) . ''; } if ( '' !== $intro ) { $tooltip_html .= '' . esc_html( $intro ) . ''; } $tooltip_html .= '' . esc_html__( 'Examples & limits', 'ai-for-seo' ) . ''; $tooltip_html .= ''; // Render each example separately so wrapping and spacing remain predictable inside the constrained tooltip. foreach ( $examples as $this_example ) { $tooltip_html .= '"' . esc_html( $this_example ) . '"'; } // Close the example list before adding constraints so the two guidance groups remain distinct. $tooltip_html .= ''; // Keep immutable usage constraints beside examples so every instruction entry point explains the allowed influence. $tooltip_html .= '' . esc_html__( 'Usage limits', 'ai-for-seo' ) . ''; $tooltip_html .= ''; $tooltip_html .= esc_html__( 'Custom instructions can guide wording, tone, terminology, emphasis, calls to action, and additional owner-supplied details. They cannot change which fields are generated, JSON or technical field formats, a fixed generation language, configured prefix/suffix behavior, official character, word, sentence, or item limits, storage caps, or safety rules. Requests for extra, repeated, padded, or unlimited output are ignored; compatible parts still apply.', 'ai-for-seo' ); $tooltip_html .= ' '; // Resolve the active cap at render time because subscription status controls the usable instruction length. $tooltip_html .= sprintf( /* translators: %1$s: Free-plan character limit. %2$s: Subscription character limit. %3$s: Current account character limit. */ esc_html__( 'Instruction text is limited to %1$s characters on Free or %2$s characters with an active subscription. Your current limit is %3$s characters.', 'ai-for-seo' ), esc_html( ai4seo_format_number_i18n( AI4SEO_CUSTOM_INSTRUCTIONS_FREE_LENGTH_LIMIT ) ), esc_html( ai4seo_format_number_i18n( AI4SEO_CUSTOM_INSTRUCTIONS_SUBSCRIPTION_LENGTH_LIMIT ) ), esc_html( ai4seo_format_number_i18n( ai4seo_get_custom_instructions_length_limit() ) ) ); $tooltip_html .= ''; // Use the shared trigger so the expanded guidance supports mouse and keyboard interaction consistently. $tooltip_trigger = $as_icon_trigger ? ai4seo_get_svg_tag( 'circle-question', '', 'ai4seo-gray-icon ai4seo-custom-instructions-examples-icon' ) : esc_html__( 'Examples & limits', 'ai-for-seo' ); $context_labels = array( 'global' => __( 'Global custom instructions', 'ai-for-seo' ), 'metadata' => __( 'Metadata custom instructions', 'ai-for-seo' ), 'metadata-post-type' => __( 'Post-type custom instructions', 'ai-for-seo' ), 'metadata-editor' => __( 'This entry\'s custom instructions', 'ai-for-seo' ), 'media-attributes' => __( 'Media attribute custom instructions', 'ai-for-seo' ), 'attachment-attributes-editor' => __( 'This media item\'s custom instructions', 'ai-for-seo' ), ); $trigger_label = sprintf( /* translators: %s: Scope of the custom instructions. */ __( 'Examples & limits: %s', 'ai-for-seo' ), $context_labels[ sanitize_key( $context ) ] ); return ai4seo_get_tooltip_tag( $tooltip_trigger, $tooltip_html, array( 'holder_css_class' => 'ai4seo-custom-instructions-examples', 'trigger_css_class' => $as_icon_trigger ? 'ai4seo-icon-tooltip-trigger ai4seo-custom-instructions-examples-trigger' : 'ai4seo-custom-instructions-examples-trigger', 'trigger_aria_label' => $trigger_label, ) ); } /** * Return a complete form item for a custom instruction textarea. * * @param string $input_id Input id. * @param string $input_name Input name. * @param string $input_value Current value. * @param string $label Visible label text. * @param string $description Description below the counter. * @param string $additional_textarea_css_classes Additional textarea classes. * @param string $field_label Label used by client-side validation. * @param string $additional_form_item_css_classes Additional wrapper classes. * @param string $label_prefix_html Optional HTML rendered before the visible label. * @param string $examples_context Optional examples tooltip context. * @param string $placeholder Optional placeholder text for textarea. * @param bool $show_description_in_tooltip Show description and examples inside a tooltip next to the label. * @param string $visible_help Optional plain-text guidance above the character counter. * @return string Form item HTML. */ function ai4seo_get_custom_instructions_form_item_tag( string $input_id, string $input_name, string $input_value, string $label, string $description = '', string $additional_textarea_css_classes = '', string $field_label = '', string $additional_form_item_css_classes = '', string $label_prefix_html = '', string $examples_context = '', string $placeholder = '', bool $show_description_in_tooltip = false, string $visible_help = '' ): string { $form_item_css_classes = trim( 'ai4seo-form-item ' . $additional_form_item_css_classes ); // Keep validation labels independent from visible labels so UI punctuation does not leak into error text. if ( '' === $field_label ) { $field_label = $label; } $active_description = trim( $description ); $active_placeholder = trim( $placeholder ); $visible_help = trim( $visible_help ); $visible_help_id = '' !== $visible_help ? $input_id . '-help' : ''; // Entry-editor tooltips provide their own concise placeholder when callers do not override it. if ( '' === $active_placeholder && $show_description_in_tooltip ) { $active_placeholder = ai4seo_get_custom_instructions_placeholder( $examples_context ); } $description_tooltip_tag = ''; if ( '' !== $active_description || '' !== $examples_context ) { $description_tooltip_tag = ai4seo_get_custom_instructions_examples_tooltip_tag( $examples_context, $active_description, $show_description_in_tooltip ); } // Reuse the shared textarea and counter helpers so settings pages and AJAX editors stay in sync. $html = '' . esc_html( $visible_help ) . '
'; } $html .= ai4seo_wp_kses( ai4seo_get_custom_instructions_character_counter_tag( $input_id ) ); // Append optional guidance inside the input wrapper so it follows the counter in every form surface. if ( ! $show_description_in_tooltip && '' !== $active_description ) { $examples_tooltip_html = ai4seo_get_custom_instructions_examples_tooltip_tag( $examples_context ); $html .= ''; $html .= ai4seo_wp_kses( $active_description ); // Only add the separator when a supported examples context returned a tooltip. if ( '' !== $examples_tooltip_html ) { $html .= ' '; $html .= ai4seo_wp_kses( $examples_tooltip_html ); } $html .= '
'; } $html .= '