From b4b480090776f16007e8c2cd54d13411a355071c Mon Sep 17 00:00:00 2001 From: yogeshbhutkar Date: Wed, 17 Jun 2026 14:40:24 +0530 Subject: [PATCH 01/23] Add Content Translation feature with support for multiple languages --- .../Content_Translation.php | 233 ++++++++++++++++++ .../Content_Translation/Languages.php | 155 ++++++++++++ .../system-instruction.php | 27 ++ .../Content_Translation.php | 100 ++++++++ includes/Experiments/Experiments.php | 1 + .../components/ContentTranslationToolbar.tsx | 62 +++++ .../hooks/useContentTranslation.ts | 96 ++++++++ .../content-translation/index.scss | 5 + src/experiments/content-translation/index.tsx | 42 ++++ .../content-translation/types/types.ts | 7 + .../content-translation/types/window.ts | 10 + webpack.config.js | 5 + 12 files changed, 743 insertions(+) create mode 100644 includes/Abilities/Content_Translation/Content_Translation.php create mode 100644 includes/Abilities/Content_Translation/Languages.php create mode 100644 includes/Abilities/Content_Translation/system-instruction.php create mode 100644 includes/Experiments/Content_Translation/Content_Translation.php create mode 100644 src/experiments/content-translation/components/ContentTranslationToolbar.tsx create mode 100644 src/experiments/content-translation/hooks/useContentTranslation.ts create mode 100644 src/experiments/content-translation/index.scss create mode 100644 src/experiments/content-translation/index.tsx create mode 100644 src/experiments/content-translation/types/types.ts create mode 100644 src/experiments/content-translation/types/window.ts diff --git a/includes/Abilities/Content_Translation/Content_Translation.php b/includes/Abilities/Content_Translation/Content_Translation.php new file mode 100644 index 000000000..8c8ccb15a --- /dev/null +++ b/includes/Abilities/Content_Translation/Content_Translation.php @@ -0,0 +1,233 @@ + 'object', + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The ID of the post to translate content for.', 'ai' ), + ), + 'content' => array( + 'type' => 'string', + 'description' => esc_html__( 'The block content to translate.', 'ai' ), + ), + 'target_language' => array( + 'type' => 'string', + 'enum' => Languages::get_codes(), + 'default' => Languages::get_default_target_language(), + 'description' => esc_html__( 'The target language for translation.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'string', + 'description' => esc_html__( 'The translated content.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function execute_callback( $input ) { + // Default arguments for the translation process. + $args = wp_parse_args( + $input, + array( + 'post_id' => null, + 'content' => null, + 'target_language' => Languages::get_default_target_language(), + ) + ); + + // Skip normalization of content to retain HTML tags. + $content = $args['content'] ?? ''; + + if ( empty( $content ) ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'No content provided for translation.', 'ai' ) + ); + } + + if ( count_words( wp_strip_all_tags( $content ) ) < self::MIN_WORDS ) { + return new WP_Error( + 'content_too_short', + sprintf( + /* translators: %d: minimum number of words required for translation */ + esc_html__( 'A minimum of %d words is required for translation.', 'ai' ), + self::MIN_WORDS + ) + ); + } + + // Validate the target language. + $target_language = sanitize_key( (string) $args['target_language'] ); + if ( ! Languages::is_supported( $target_language ) ) { + return new WP_Error( + 'invalid_target_language', + esc_html__( 'The specified target language is not supported for translation.', 'ai' ) + ); + } + + $language = Languages::get_language_name( $target_language ); + + $prompt = sprintf( '%s', $content ); + + $result = $this->generate_translated_content( $prompt, $language ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No translated content was generated.', 'ai' ) + ); + } + + return wp_kses_post( $result ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + // Ensure the user has permission to edit the post if a post ID is provided. + if ( isset( $args['post_id'] ) ) { + $post_id = absint( $args['post_id'] ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + sprintf( + /* translators: %d: post ID */ + esc_html__( 'Post with ID %d not found.', 'ai' ), + $post_id + ) + ); + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_permissions', + esc_html__( 'You do not have permission to edit this post.', 'ai' ) + ); + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'insufficient_permissions', + esc_html__( 'You do not have permission to translate content.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Generates translated content using the AI Client. + * + * @since x.x.x + * + * @param string $prompt The prompt to use for the content translation. + * @param string $target_language The target language for the translation. + * @return string|\WP_Error The translated content, or a WP_Error if there was an error. + */ + protected function generate_translated_content( string $prompt, string $target_language ) { + $builder = $this->get_prompt_builder( $prompt, $target_language ); + + if ( is_wp_error( $builder ) ) { + return $builder; + } + + return $builder->generate_text(); + } + + /** + * Returns a prompt builder for content translation. + * + * @since x.x.x + * + * @param string $prompt The prompt to build. + * @param string $target_language The target language. + * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or a WP_Error if there isn't a model that supports text generation. + */ + private function get_prompt_builder( string $prompt, string $target_language ) { + $prompt_builder = wp_ai_client_prompt( $prompt ) + ->using_system_instruction( + $this->get_system_instruction( + 'system-instruction.php', + array( + 'target_language' => $target_language, + ) + ) + ) + ->using_temperature( 0.7 ); + + $prompt_builder = $this->set_provider_model_preference( + $prompt_builder, + Content_Translation_Experiment::class + ); + + return $this->ensure_text_generation_supported( + $prompt_builder, + esc_html__( 'Content translation failed. Please ensure you have a connected provider that supports text generation.', 'ai' ) + ); + } +} diff --git a/includes/Abilities/Content_Translation/Languages.php b/includes/Abilities/Content_Translation/Languages.php new file mode 100644 index 000000000..f5243bcbd --- /dev/null +++ b/includes/Abilities/Content_Translation/Languages.php @@ -0,0 +1,155 @@ + Supported languages. + */ + public static function get_supported_languages(): array { + $languages = array( + 'en-us' => array( + 'locale' => 'en_US', + 'name' => __( 'English (US)', 'ai' ), + ), + 'en-gb' => array( + 'locale' => 'en_GB', + 'name' => __( 'English (UK)', 'ai' ), + ), + 'es-es' => array( + 'locale' => 'es_ES', + 'name' => __( 'Spanish (Spain)', 'ai' ), + ), + 'fr-fr' => array( + 'locale' => 'fr_FR', + 'name' => __( 'French (France)', 'ai' ), + ), + 'de-de' => array( + 'locale' => 'de_DE', + 'name' => __( 'German', 'ai' ), + ), + 'it-it' => array( + 'locale' => 'it_IT', + 'name' => __( 'Italian', 'ai' ), + ), + 'pt-br' => array( + 'locale' => 'pt_BR', + 'name' => __( 'Portuguese (Brazil)', 'ai' ), + ), + 'nl-nl' => array( + 'locale' => 'nl_NL', + 'name' => __( 'Dutch', 'ai' ), + ), + 'ja' => array( + 'locale' => 'ja', + 'name' => __( 'Japanese', 'ai' ), + ), + 'zh-cn' => array( + 'locale' => 'zh_CN', + 'name' => __( 'Chinese (Simplified)', 'ai' ), + ), + ); + + /** + * Filters supported target languages for AI content translation. + * + * @param array $languages Supported languages. + */ + return (array) apply_filters( 'wpai_content_translation_languages', $languages ); + } + + /** + * Returns the supported languages for AI content translation in a format suitable for JavaScript. + * + * @since x.x.x + * + * @return array Supported languages for JavaScript. + */ + public static function get_supported_languages_for_js(): array { + $languages = self::get_supported_languages(); + + return array_map( + static function ( string $code, array $language ): array { + return array( + 'code' => $code, + 'name' => $language['name'], + ); + }, + array_keys( $languages ), + $languages + ); + } + + /** + * Returns the name of a language given its code. + * + * @since x.x.x + * + * @param string $language_code The language code. + * @return string|null The name of the language, or null if not found. + */ + public static function get_language_name( string $language_code ): ?string { + $languages = self::get_supported_languages(); + + if ( isset( $languages[ $language_code ] ) ) { + return $languages[ $language_code ]['name']; + } + + return null; + } + + /** + * Returns the language codes of supported languages. + * + * @since x.x.x + * + * @return string[] Array of supported language codes. + */ + public static function get_codes(): array { + return array_keys( self::get_supported_languages() ); + } + + /** + * Checks if a language is supported for translation. + * + * @since x.x.x + * + * @param string $language_code The language code to check. + * @return bool True if the language is supported, false otherwise. + */ + public static function is_supported( string $language_code ): bool { + return array_key_exists( $language_code, self::get_supported_languages() ); + } +} diff --git a/includes/Abilities/Content_Translation/system-instruction.php b/includes/Abilities/Content_Translation/system-instruction.php new file mode 100644 index 000000000..85c5e4fbe --- /dev/null +++ b/includes/Abilities/Content_Translation/system-instruction.php @@ -0,0 +1,27 @@ +` tags) into a different language while preserving meaning and intent. The content may contain inline HTML tags (such as strong, em, a, code). + +Goal: Translate the content into {$target_language}. Ensure that the translation is accurate, natural, and fluent in {$target_language}. + +Requirements: +- Return only the translated text, nothing else. +- Do not include any preamble, explanation, or commentary. +- Return content in the same format as it was provided. For example, preserve any inline HTML like links. +- Match the target language specified. For example, if the target language is {$target_language}, return the content in {$target_language}. +- Maintain the original perspective and voice. +INSTRUCTION; diff --git a/includes/Experiments/Content_Translation/Content_Translation.php b/includes/Experiments/Content_Translation/Content_Translation.php new file mode 100644 index 000000000..2a3773202 --- /dev/null +++ b/includes/Experiments/Content_Translation/Content_Translation.php @@ -0,0 +1,100 @@ + __( 'Content Translation', 'ai' ), + 'description' => __( 'Translate block content into a different language. Requires an AI connector that includes support for text generation models.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Registers required abilities + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + sprintf( 'ai/%s', $this->get_id() ), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Content_Translation_Ability::class, + ) + ); + } + + public function enqueue_assets( string $hook_suffix ): void { + // Enqueue assets only on the post editor screen. + if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { + return; + } + + Asset_Loader::enqueue_script( + 'content_translation', + 'experiments/content-translation', + array( + 'include_core_abilities' => true, + ) + ); + + Asset_Loader::enqueue_style( + 'content_translation', + 'experiments/content-translation' + ); + + Asset_Loader::localize_script( + 'content_translation', + 'ContentTranslationData', + array( + 'enabled' => $this->is_enabled(), + 'languages' => Languages::get_supported_languages_for_js(), + ) + ); + } +} diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index 61a35d85a..68c629aef 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -40,6 +40,7 @@ final class Experiments { \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, + \WordPress\AI\Experiments\Content_Translation\Content_Translation::class, ); /** diff --git a/src/experiments/content-translation/components/ContentTranslationToolbar.tsx b/src/experiments/content-translation/components/ContentTranslationToolbar.tsx new file mode 100644 index 000000000..f9e87f4ba --- /dev/null +++ b/src/experiments/content-translation/components/ContentTranslationToolbar.tsx @@ -0,0 +1,62 @@ +/** + * WordPress dependencies + */ +import { + Spinner, + ToolbarDropdownMenu, + ToolbarGroup, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useContentTranslation } from '../hooks/useContentTranslation'; + +type ContentTranslationToolbarProps = { + clientId: string; +}; + +/** + * Content translation toolbar component. + * + * @param props Component props. + * @param props.clientId The block client ID. + */ +export default function ContentTranslationToolbar( { + clientId, +}: ContentTranslationToolbarProps ) { + const { isLoading, translate, canTranslate } = + useContentTranslation( clientId ); + + const controls = useMemo( + () => + window?.aiContentTranslationData?.languages.map( ( language ) => ( { + title: language.name, + isDisabled: ! canTranslate, + onClick: () => { + translate( language.code ); + }, + } ) ), + [ translate, canTranslate ] + ); + + return ( + <> + + + ) : ( + 'translation' + ) + } + label={ __( 'AI Translate', 'ai' ) } + controls={ controls } + /> + + + ); +} diff --git a/src/experiments/content-translation/hooks/useContentTranslation.ts b/src/experiments/content-translation/hooks/useContentTranslation.ts new file mode 100644 index 000000000..c0e0c3850 --- /dev/null +++ b/src/experiments/content-translation/hooks/useContentTranslation.ts @@ -0,0 +1,96 @@ +/** + * WordPress dependencies + */ +import { useCallback, useState } from '@wordpress/element'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; +import { store as noticesStore } from '@wordpress/notices'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import { getBlockText } from '../../../utils/blocks'; + +/** + * Notice ID for the content translation error notice. + */ +const NOTICE_ID = 'ai_content_translation_error'; + +type UseContentTranslationReturn = { + translate: ( languageCode: string ) => Promise< void >; + canTranslate: boolean; + isLoading: boolean; +}; + +/** + * Hook to handle content translation for a specific block. + * + * @param clientId The block client ID. + * @return An object containing the translate function, canTranslate boolean, and isLoading boolean. + */ +export function useContentTranslation( + clientId: string +): UseContentTranslationReturn { + const [ isLoading, setIsLoading ] = useState( false ); + + const noticeDispatch = useDispatch( noticesStore ); + const blockEditorDispatch = useDispatch( blockEditorStore ); + + const { blockContent, postId } = useSelect( + ( select ) => { + const block = select( blockEditorStore ).getBlock( clientId ); + return { + blockContent: block ? getBlockText( block ) : '', + postId: select( editorStore ).getCurrentPostId() as number, + }; + }, + [ clientId ] + ); + + const translate = useCallback( + async ( languageCode: string ) => { + noticeDispatch.removeNotice( NOTICE_ID ); + + setIsLoading( true ); + + try { + const result = await runAbility< string >( + 'ai/content-translation', + { + content: blockContent, + target_language: languageCode, + post_id: postId, + } + ); + + blockEditorDispatch.updateBlockAttributes( clientId, { + content: result, + } ); + } catch ( error: unknown ) { + const message = + error instanceof Error + ? error.message + : __( + 'An error occured while translating the content.', + 'ai' + ); + + noticeDispatch.createErrorNotice( message, { + id: NOTICE_ID, + } ); + } finally { + setIsLoading( false ); + } + }, + [ blockContent, postId, noticeDispatch, blockEditorDispatch, clientId ] + ); + + return { + isLoading, + translate, + canTranslate: blockContent.trim().length > 0, + }; +} diff --git a/src/experiments/content-translation/index.scss b/src/experiments/content-translation/index.scss new file mode 100644 index 000000000..61322d5f5 --- /dev/null +++ b/src/experiments/content-translation/index.scss @@ -0,0 +1,5 @@ +.ai-content-translation-toolbar { + &__spinner.components-spinner { + margin-top: 0; + } +} diff --git a/src/experiments/content-translation/index.tsx b/src/experiments/content-translation/index.tsx new file mode 100644 index 000000000..dd36f1b69 --- /dev/null +++ b/src/experiments/content-translation/index.tsx @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { BlockControls } from '@wordpress/block-editor'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import ContentTranslationToolbar from './components/ContentTranslationToolbar'; +import './index.scss'; + +const withContentTranslation = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + if ( + props.name !== 'core/paragraph' || + ! window.aiContentTranslationData?.enabled + ) { + return ; + } + + return ( + <> + { props.isSelected && ( + + + + ) } + + + ); + }; +}, 'withContentTranslation' ); + +addFilter( + 'editor.BlockEdit', + 'ai/content-translation', + withContentTranslation +); diff --git a/src/experiments/content-translation/types/types.ts b/src/experiments/content-translation/types/types.ts new file mode 100644 index 000000000..5922cfbba --- /dev/null +++ b/src/experiments/content-translation/types/types.ts @@ -0,0 +1,7 @@ +export type AIContentTranslationData = { + enabled: boolean; + languages: Array< { + code: string; + name: string; + } >; +}; diff --git a/src/experiments/content-translation/types/window.ts b/src/experiments/content-translation/types/window.ts new file mode 100644 index 000000000..d10eb9c89 --- /dev/null +++ b/src/experiments/content-translation/types/window.ts @@ -0,0 +1,10 @@ +/** + * Internal dependencies + */ +import type { AIContentTranslationData } from './types'; + +declare global { + interface Window { + aiContentTranslationData: AIContentTranslationData; + } +} diff --git a/webpack.config.js b/webpack.config.js index 454133270..7882107c4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -120,6 +120,11 @@ module.exports = { 'src/experiments/alt-text-generation', 'bulk.ts' ), + 'experiments/content-translation': path.resolve( + process.cwd(), + 'src/experiments/content-translation', + 'index.tsx' + ) }, plugins: [ From d88063a0f10f5d9232dcc5dab8155b6821f26572 Mon Sep 17 00:00:00 2001 From: yogeshbhutkar Date: Wed, 17 Jun 2026 16:23:26 +0530 Subject: [PATCH 02/23] Add shimmer effect to generating text --- .../Content_Translation.php | 13 ++++++ .../components/ContentTranslationToolbar.tsx | 22 +++++----- .../content-translation/index.scss | 34 ++++++++++++++ src/experiments/content-translation/index.tsx | 44 ++++++++++++++----- 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/includes/Experiments/Content_Translation/Content_Translation.php b/includes/Experiments/Content_Translation/Content_Translation.php index 2a3773202..45c7cb2ff 100644 --- a/includes/Experiments/Content_Translation/Content_Translation.php +++ b/includes/Experiments/Content_Translation/Content_Translation.php @@ -51,6 +51,7 @@ protected function load_metadata(): array { public function register(): void { add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) ); } /** @@ -97,4 +98,16 @@ public function enqueue_assets( string $hook_suffix ): void { ) ); } + + /** + * Enqueues the block stylesheet for the editor canvas. + * + * @since x.x.x + */ + public function enqueue_block_assets(): void { + Asset_Loader::enqueue_style( + 'content_translation', + 'experiments/content-translation' + ); + } } diff --git a/src/experiments/content-translation/components/ContentTranslationToolbar.tsx b/src/experiments/content-translation/components/ContentTranslationToolbar.tsx index f9e87f4ba..c5bb9e547 100644 --- a/src/experiments/content-translation/components/ContentTranslationToolbar.tsx +++ b/src/experiments/content-translation/components/ContentTranslationToolbar.tsx @@ -9,27 +9,25 @@ import { import { __ } from '@wordpress/i18n'; import { useMemo } from '@wordpress/element'; -/** - * Internal dependencies - */ -import { useContentTranslation } from '../hooks/useContentTranslation'; - type ContentTranslationToolbarProps = { - clientId: string; + isLoading: boolean; + translate: ( languageCode: string ) => Promise< void >; + canTranslate: boolean; }; /** * Content translation toolbar component. * - * @param props Component props. - * @param props.clientId The block client ID. + * @param props Component props. + * @param props.isLoading Whether a translation request is in progress. + * @param props.translate Callback to translate the selected block. + * @param props.canTranslate Whether the selected block can be translated. */ export default function ContentTranslationToolbar( { - clientId, + isLoading, + translate, + canTranslate, }: ContentTranslationToolbarProps ) { - const { isLoading, translate, canTranslate } = - useContentTranslation( clientId ); - const controls = useMemo( () => window?.aiContentTranslationData?.languages.map( ( language ) => ( { diff --git a/src/experiments/content-translation/index.scss b/src/experiments/content-translation/index.scss index 61322d5f5..4664daf96 100644 --- a/src/experiments/content-translation/index.scss +++ b/src/experiments/content-translation/index.scss @@ -1,3 +1,37 @@ +@keyframes ai-content-translation-text-shimmer { + 0% { + background-position: 180% 0; + } + 100% { + background-position: -80% 0; + } +} + +.ai-content-translation-content--is-loading .block-editor-rich-text__editable { + color: var(--wpds-color-fg-content-neutral-weak, #707070); + + @media (prefers-reduced-motion: no-preference) { + background: linear-gradient( + 90deg, + var(--wpds-color-fg-content-neutral-weak, #707070) 30%, + color-mix( + in srgb, + var(--wpds-color-fg-content-neutral-weak, #707070) 55%, + #fff + ) + 50%, + var(--wpds-color-fg-content-neutral-weak, #707070) 70% + ); + background-clip: text; + background-size: 250% 100%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: ai-content-translation-text-shimmer 1.1s linear infinite; + animation-delay: -0.35s; + animation-fill-mode: both; + } +} + .ai-content-translation-toolbar { &__spinner.components-spinner { margin-top: 0; diff --git a/src/experiments/content-translation/index.tsx b/src/experiments/content-translation/index.tsx index dd36f1b69..4d37be76f 100644 --- a/src/experiments/content-translation/index.tsx +++ b/src/experiments/content-translation/index.tsx @@ -9,8 +9,38 @@ import { addFilter } from '@wordpress/hooks'; * Internal dependencies */ import ContentTranslationToolbar from './components/ContentTranslationToolbar'; +import { useContentTranslation } from './hooks/useContentTranslation'; import './index.scss'; +function ContentTranslationBlockEdit( { BlockEdit, props }: any ) { + const { isLoading, translate, canTranslate } = useContentTranslation( + props.clientId + ); + + return ( + <> + { props.isSelected && ( + + + + ) } +
+ +
+ + ); +} + const withContentTranslation = createHigherOrderComponent( ( BlockEdit ) => { return ( props ) => { if ( @@ -21,16 +51,10 @@ const withContentTranslation = createHigherOrderComponent( ( BlockEdit ) => { } return ( - <> - { props.isSelected && ( - - - - ) } - - + ); }; }, 'withContentTranslation' ); From 3b1cf61b1314b2849c353c038d73c8d470b8ff4a Mon Sep 17 00:00:00 2001 From: yogeshbhutkar Date: Thu, 18 Jun 2026 14:11:02 +0530 Subject: [PATCH 03/23] Implement translation generation at the sidebar --- .../Content_Translation/Languages.php | 16 ++ .../Content_Translation.php | 7 +- .../components/ContentTranslationPlugin.tsx | 92 +++++++++ .../components/ContentTranslationToolbar.tsx | 60 ------ .../components/TranslationModal.tsx | 84 +++++++++ .../content-translation/constants.ts | 23 +++ .../hooks/useContentTranslation.ts | 175 ++++++++++++------ .../content-translation/index.scss | 14 +- src/experiments/content-translation/index.tsx | 62 +------ .../content-translation/types/types.ts | 1 + .../content-translation/utils/index.ts | 71 +++++++ 11 files changed, 426 insertions(+), 179 deletions(-) create mode 100644 src/experiments/content-translation/components/ContentTranslationPlugin.tsx delete mode 100644 src/experiments/content-translation/components/ContentTranslationToolbar.tsx create mode 100644 src/experiments/content-translation/components/TranslationModal.tsx create mode 100644 src/experiments/content-translation/constants.ts create mode 100644 src/experiments/content-translation/utils/index.ts diff --git a/includes/Abilities/Content_Translation/Languages.php b/includes/Abilities/Content_Translation/Languages.php index f5243bcbd..e8e22ed91 100644 --- a/includes/Abilities/Content_Translation/Languages.php +++ b/includes/Abilities/Content_Translation/Languages.php @@ -80,6 +80,22 @@ public static function get_supported_languages(): array { 'locale' => 'zh_CN', 'name' => __( 'Chinese (Simplified)', 'ai' ), ), + 'zh-tw' => array( + 'locale' => 'zh_TW', + 'name' => __( 'Chinese (Traditional)', 'ai' ), + ), + 'ko' => array( + 'locale' => 'ko', + 'name' => __( 'Korean', 'ai' ), + ), + 'ar' => array( + 'locale' => 'ar', + 'name' => __( 'Arabic', 'ai' ), + ), + 'hi' => array( + 'locale' => 'hi', + 'name' => __( 'Hindi', 'ai' ), + ), ); /** diff --git a/includes/Experiments/Content_Translation/Content_Translation.php b/includes/Experiments/Content_Translation/Content_Translation.php index 45c7cb2ff..5bb9d4a7d 100644 --- a/includes/Experiments/Content_Translation/Content_Translation.php +++ b/includes/Experiments/Content_Translation/Content_Translation.php @@ -15,6 +15,8 @@ use WordPress\AI\Asset_Loader; use WordPress\AI\Experiments\Experiment_Category; +use function WordPress\AI\get_min_content_length; + // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; @@ -93,8 +95,9 @@ public function enqueue_assets( string $hook_suffix ): void { 'content_translation', 'ContentTranslationData', array( - 'enabled' => $this->is_enabled(), - 'languages' => Languages::get_supported_languages_for_js(), + 'enabled' => $this->is_enabled(), + 'minContentLength' => get_min_content_length( 'content-translation', 15 ), + 'languages' => Languages::get_supported_languages_for_js(), ) ); } diff --git a/src/experiments/content-translation/components/ContentTranslationPlugin.tsx b/src/experiments/content-translation/components/ContentTranslationPlugin.tsx new file mode 100644 index 000000000..d800ab592 --- /dev/null +++ b/src/experiments/content-translation/components/ContentTranslationPlugin.tsx @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { PluginPostStatusInfo } from '@wordpress/editor'; +import { __, sprintf } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TranslationModal from './TranslationModal'; +import { useContentTranslation } from '../hooks/useContentTranslation'; +import { formatMinLengthLabel } from '../../../utils/word-count'; + +export default function ContentTranslationPlugin() { + const [ isOpen, setIsOpen ] = useState( false ); + const { + isLoading: isTranslating, + isContentTooShort, + progress, + total, + minContentLength, + translate, + } = useContentTranslation(); + + const buttonLabel = isTranslating + ? sprintf( + /* translators: %1$d: number of translated blocks, %2$d: total number of blocks */ + __( 'Translating blocks… (%1$d/%2$d)', 'ai' ), + progress, + total + ) + : __( 'Generate Translation', 'ai' ); + + const buttonDescription = isContentTooShort + ? formatMinLengthLabel( + /* translators: %d: minimum number of characters required */ + __( + 'Content translation will be available when the post content has at least %d characters.', + 'ai' + ), + /* translators: %d: minimum number of words required */ + __( + 'Content translation will be available when the post content has at least %d words.', + 'ai' + ), + minContentLength + ) + : __( + 'Translates this post block by block and applies the translated content to each block.', + 'ai' + ); + + return ( + + + + + { isOpen && ( + setIsOpen( false ) } + translate={ translate } + /> + ) } + + + { buttonDescription } + + + + ); +} diff --git a/src/experiments/content-translation/components/ContentTranslationToolbar.tsx b/src/experiments/content-translation/components/ContentTranslationToolbar.tsx deleted file mode 100644 index c5bb9e547..000000000 --- a/src/experiments/content-translation/components/ContentTranslationToolbar.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * WordPress dependencies - */ -import { - Spinner, - ToolbarDropdownMenu, - ToolbarGroup, -} from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { useMemo } from '@wordpress/element'; - -type ContentTranslationToolbarProps = { - isLoading: boolean; - translate: ( languageCode: string ) => Promise< void >; - canTranslate: boolean; -}; - -/** - * Content translation toolbar component. - * - * @param props Component props. - * @param props.isLoading Whether a translation request is in progress. - * @param props.translate Callback to translate the selected block. - * @param props.canTranslate Whether the selected block can be translated. - */ -export default function ContentTranslationToolbar( { - isLoading, - translate, - canTranslate, -}: ContentTranslationToolbarProps ) { - const controls = useMemo( - () => - window?.aiContentTranslationData?.languages.map( ( language ) => ( { - title: language.name, - isDisabled: ! canTranslate, - onClick: () => { - translate( language.code ); - }, - } ) ), - [ translate, canTranslate ] - ); - - return ( - <> - - - ) : ( - 'translation' - ) - } - label={ __( 'AI Translate', 'ai' ) } - controls={ controls } - /> - - - ); -} diff --git a/src/experiments/content-translation/components/TranslationModal.tsx b/src/experiments/content-translation/components/TranslationModal.tsx new file mode 100644 index 000000000..b4ce0f81c --- /dev/null +++ b/src/experiments/content-translation/components/TranslationModal.tsx @@ -0,0 +1,84 @@ +/** + * WordPress dependencies + */ +import { Button, Modal, SelectControl } from '@wordpress/components'; +import { useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Stack } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import { getSettings } from '../utils'; + +type TranslationModalProps = { + canTranslate: boolean; + closeModal: () => void; + translate: ( languageCode: string ) => Promise< void >; +}; + +/** + * TranslationModal component. + * + * @param props Component props. + * @param props.canTranslate Whether translation can be started. + * @param props.closeModal Callback to close the modal. + * @param props.translate Callback to translate content. + */ +export default function TranslationModal( { + canTranslate, + closeModal, + translate, +}: TranslationModalProps ) { + const controls = useMemo( + () => + getSettings().languages.map( ( language ) => ( { + label: language.name, + value: language.code, + } ) ), + [] + ); + + const [ selectedLanguage, setSelectedLanguage ] = useState( + controls?.[ 0 ]?.value || '' + ); + + return ( + + + setSelectedLanguage( value ) } + __next40pxDefaultSize + /> + + + + + + + + ); +} diff --git a/src/experiments/content-translation/constants.ts b/src/experiments/content-translation/constants.ts new file mode 100644 index 000000000..517ffcfa2 --- /dev/null +++ b/src/experiments/content-translation/constants.ts @@ -0,0 +1,23 @@ +/** + * A default minimum content length for enabling content translation. + */ +export const TRANSLATION_MINIMUM_CONTENT_COUNT_DEFAULT = 15; + +/** + * Notice ID for the content translation error notice. + */ +export const TRANSLATION_NOTICE_ID = 'ai_content_translation_error'; + +/** + * Batch size for content translation. + */ +export const TRANSLATION_BATCH_SIZE = 4; + +/** + * Supported block types for content translation. + */ +export const TRANSLATION_SUPPORTED_BLOCK_TYPES = [ + 'core/paragraph', + 'core/heading', + 'core/quote', +]; diff --git a/src/experiments/content-translation/hooks/useContentTranslation.ts b/src/experiments/content-translation/hooks/useContentTranslation.ts index c0e0c3850..4c6a7b1db 100644 --- a/src/experiments/content-translation/hooks/useContentTranslation.ts +++ b/src/experiments/content-translation/hooks/useContentTranslation.ts @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useCallback, useState } from '@wordpress/element'; +import { useState } from '@wordpress/element'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as editorStore } from '@wordpress/editor'; import { store as noticesStore } from '@wordpress/notices'; @@ -11,86 +11,149 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ +import { ensureProvider } from '../../../utils/provider-status'; import { runAbility } from '../../../utils/run-ability'; -import { getBlockText } from '../../../utils/blocks'; - -/** - * Notice ID for the content translation error notice. - */ -const NOTICE_ID = 'ai_content_translation_error'; +import { flattenBlocks } from '../../../utils/blocks'; +import { hasMinimumContent } from '../../../utils/word-count'; +import { + getSettings, + getTranslatableBlock, + setTranslationLoadingClass, +} from '../utils'; +import { TRANSLATION_BATCH_SIZE, TRANSLATION_NOTICE_ID } from '../constants'; type UseContentTranslationReturn = { - translate: ( languageCode: string ) => Promise< void >; - canTranslate: boolean; + isContentTooShort: boolean; isLoading: boolean; + progress: number; + total: number; + minContentLength: number; + translate: ( languageCode: string ) => Promise< void >; }; /** - * Hook to handle content translation for a specific block. + * Handles the content translation process, including managing loading state, progress, and error handling. * - * @param clientId The block client ID. - * @return An object containing the translate function, canTranslate boolean, and isLoading boolean. + * @return {UseContentTranslationReturn} An object containing the translation state and functions. */ -export function useContentTranslation( - clientId: string -): UseContentTranslationReturn { +export function useContentTranslation(): UseContentTranslationReturn { const [ isLoading, setIsLoading ] = useState( false ); + const [ progress, setProgress ] = useState( 0 ); + const [ total, setTotal ] = useState( 0 ); const noticeDispatch = useDispatch( noticesStore ); const blockEditorDispatch = useDispatch( blockEditorStore ); - const { blockContent, postId } = useSelect( - ( select ) => { - const block = select( blockEditorStore ).getBlock( clientId ); - return { - blockContent: block ? getBlockText( block ) : '', - postId: select( editorStore ).getCurrentPostId() as number, - }; - }, - [ clientId ] + const { postId, allBlocks, content } = useSelect( ( select ) => { + return { + postId: select( editorStore ).getCurrentPostId(), + allBlocks: select( blockEditorStore ).getBlocks(), + content: select( editorStore ).getEditedPostContent(), + }; + }, [] ); + + const isContentTooShort = ! hasMinimumContent( + content || '', + getSettings().minContentLength ); - const translate = useCallback( - async ( languageCode: string ) => { - noticeDispatch.removeNotice( NOTICE_ID ); + const translate = async ( languageCode: string ) => { + if ( ! ensureProvider( TRANSLATION_NOTICE_ID ) ) { + return; + } - setIsLoading( true ); + if ( isContentTooShort ) { + return; + } - try { - const result = await runAbility< string >( - 'ai/content-translation', - { - content: blockContent, - target_language: languageCode, - post_id: postId, - } + setProgress( 0 ); + setTotal( 0 ); + + noticeDispatch.removeNotice( TRANSLATION_NOTICE_ID ); + + const translatableBlocks = flattenBlocks( allBlocks ) + .map( ( block ) => getTranslatableBlock( block ) ) + .filter( ( block ) => block !== null ); + + if ( translatableBlocks.length === 0 ) { + noticeDispatch.createErrorNotice( + __( 'No translatable content found in the post.', 'ai' ), + { + id: TRANSLATION_NOTICE_ID, + } + ); + + return; + } + + setTotal( translatableBlocks.length ); + setIsLoading( true ); + setTranslationLoadingClass( true ); + + try { + // Process blocks in batches. + for ( + let batchStart = 0; + batchStart < translatableBlocks.length; + batchStart += TRANSLATION_BATCH_SIZE + ) { + const batch = translatableBlocks.slice( + batchStart, + batchStart + TRANSLATION_BATCH_SIZE ); - blockEditorDispatch.updateBlockAttributes( clientId, { - content: result, - } ); - } catch ( error: unknown ) { - const message = - error instanceof Error - ? error.message - : __( - 'An error occured while translating the content.', - 'ai' - ); - - noticeDispatch.createErrorNotice( message, { - id: NOTICE_ID, + const results = await Promise.all( + batch.map( ( block ) => + runAbility< string >( 'ai/content-translation', { + content: block.content, + target_language: languageCode, + post_id: postId, + } ) + ) + ); + + results.forEach( ( result, index ) => { + if ( ! result || ! batch[ index ] ) { + return; + } + + const { clientId } = batch[ index ]; + blockEditorDispatch.updateBlockAttributes( clientId, { + content: result, + } ); } ); - } finally { - setIsLoading( false ); + + setProgress( + Math.min( + batchStart + TRANSLATION_BATCH_SIZE, + translatableBlocks.length + ) + ); } - }, - [ blockContent, postId, noticeDispatch, blockEditorDispatch, clientId ] - ); + } catch ( error: unknown ) { + const message = + error instanceof Error + ? error.message + : __( + 'An error occured while translating the content.', + 'ai' + ); + + noticeDispatch.createErrorNotice( message, { + id: TRANSLATION_NOTICE_ID, + } ); + } finally { + setIsLoading( false ); + setTranslationLoadingClass( false ); + } + }; return { isLoading, + isContentTooShort, + progress, + total, + minContentLength: getSettings().minContentLength, translate, - canTranslate: blockContent.trim().length > 0, }; } diff --git a/src/experiments/content-translation/index.scss b/src/experiments/content-translation/index.scss index 4664daf96..2651a9af2 100644 --- a/src/experiments/content-translation/index.scss +++ b/src/experiments/content-translation/index.scss @@ -7,7 +7,8 @@ } } -.ai-content-translation-content--is-loading .block-editor-rich-text__editable { +.block-editor-iframe__body.ai-content-translation--is-loading + .block-editor-rich-text__editable { color: var(--wpds-color-fg-content-neutral-weak, #707070); @media (prefers-reduced-motion: no-preference) { @@ -32,8 +33,13 @@ } } -.ai-content-translation-toolbar { - &__spinner.components-spinner { - margin-top: 0; +.ai-content-translation-plugin { + &__trigger.components-button.has-icon.has-text { + width: 100%; + justify-content: center; + } + + &__description { + color: var(--wpds-color-fg-content-neutral-weak, #757575); } } diff --git a/src/experiments/content-translation/index.tsx b/src/experiments/content-translation/index.tsx index 4d37be76f..29e062d5e 100644 --- a/src/experiments/content-translation/index.tsx +++ b/src/experiments/content-translation/index.tsx @@ -1,66 +1,14 @@ /** * WordPress dependencies */ -import { BlockControls } from '@wordpress/block-editor'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import { addFilter } from '@wordpress/hooks'; +import { registerPlugin } from '@wordpress/plugins'; /** * Internal dependencies */ -import ContentTranslationToolbar from './components/ContentTranslationToolbar'; -import { useContentTranslation } from './hooks/useContentTranslation'; +import ContentTranslationPlugin from './components/ContentTranslationPlugin'; import './index.scss'; -function ContentTranslationBlockEdit( { BlockEdit, props }: any ) { - const { isLoading, translate, canTranslate } = useContentTranslation( - props.clientId - ); - - return ( - <> - { props.isSelected && ( - - - - ) } -
- -
- - ); -} - -const withContentTranslation = createHigherOrderComponent( ( BlockEdit ) => { - return ( props ) => { - if ( - props.name !== 'core/paragraph' || - ! window.aiContentTranslationData?.enabled - ) { - return ; - } - - return ( - - ); - }; -}, 'withContentTranslation' ); - -addFilter( - 'editor.BlockEdit', - 'ai/content-translation', - withContentTranslation -); +registerPlugin( 'ai-content-translation', { + render: () => , +} ); diff --git a/src/experiments/content-translation/types/types.ts b/src/experiments/content-translation/types/types.ts index 5922cfbba..350bbdaae 100644 --- a/src/experiments/content-translation/types/types.ts +++ b/src/experiments/content-translation/types/types.ts @@ -1,5 +1,6 @@ export type AIContentTranslationData = { enabled: boolean; + minContentLength: number; languages: Array< { code: string; name: string; diff --git a/src/experiments/content-translation/utils/index.ts b/src/experiments/content-translation/utils/index.ts new file mode 100644 index 000000000..3603bc225 --- /dev/null +++ b/src/experiments/content-translation/utils/index.ts @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import type { Block } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { getBlockText } from '../../../utils/blocks'; +import type { AIContentTranslationData } from '../types/types'; +import { + TRANSLATION_MINIMUM_CONTENT_COUNT_DEFAULT, + TRANSLATION_SUPPORTED_BLOCK_TYPES, +} from '../constants'; + +/** + * Retrieves the content translation settings from the global window object. + * + * @return {AIContentTranslationData} The content translation settings. + */ +export const getSettings = (): AIContentTranslationData => { + const settings = window?.aiContentTranslationData ?? {}; + + return { + enabled: settings.enabled ?? false, + minContentLength: + settings.minContentLength ?? + TRANSLATION_MINIMUM_CONTENT_COUNT_DEFAULT, + languages: settings.languages ?? [], + }; +}; + +/** + * Get the translatable block if it is supported or has non-empty text content. + * + * @param block The block to check. + * @return An object containing the clientId and content of the block, or null if the block is not translatable. + */ +export function getTranslatableBlock( block: Block ) { + const content = getBlockText( block ); + + if ( + TRANSLATION_SUPPORTED_BLOCK_TYPES.includes( block.name ) && + content.trim().length > 0 + ) { + return { + clientId: block.clientId, + content, + }; + } + + return null; +} + +/** + * Toggle a CSS class on the editor body to indicate whether the translation process is currently loading. + * + * @param isLoading A boolean indicating whether the translation process is currently loading. + */ +export function setTranslationLoadingClass( isLoading: boolean ) { + const editorBody = + document.querySelector< HTMLIFrameElement >( + 'iframe[name="editor-canvas"]' + )?.contentDocument?.body ?? + document.querySelector< HTMLElement >( '.editor-styles-wrapper' ); + + editorBody?.classList.toggle( + 'ai-content-translation--is-loading', + isLoading + ); +} From 865cc142e5ee732dfbd550890539a75098414167 Mon Sep 17 00:00:00 2001 From: yogeshbhutkar Date: Thu, 18 Jun 2026 14:23:00 +0530 Subject: [PATCH 04/23] Extract error message function --- .../hooks/useContentTranslation.ts | 13 +++---------- .../content-translation/utils/index.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/experiments/content-translation/hooks/useContentTranslation.ts b/src/experiments/content-translation/hooks/useContentTranslation.ts index 4c6a7b1db..915c85ded 100644 --- a/src/experiments/content-translation/hooks/useContentTranslation.ts +++ b/src/experiments/content-translation/hooks/useContentTranslation.ts @@ -16,6 +16,7 @@ import { runAbility } from '../../../utils/run-ability'; import { flattenBlocks } from '../../../utils/blocks'; import { hasMinimumContent } from '../../../utils/word-count'; import { + getErrorMessage, getSettings, getTranslatableBlock, setTranslationLoadingClass, @@ -130,16 +131,8 @@ export function useContentTranslation(): UseContentTranslationReturn { ) ); } - } catch ( error: unknown ) { - const message = - error instanceof Error - ? error.message - : __( - 'An error occured while translating the content.', - 'ai' - ); - - noticeDispatch.createErrorNotice( message, { + } catch ( error ) { + noticeDispatch.createErrorNotice( getErrorMessage( error ), { id: TRANSLATION_NOTICE_ID, } ); } finally { diff --git a/src/experiments/content-translation/utils/index.ts b/src/experiments/content-translation/utils/index.ts index 3603bc225..e32585ee5 100644 --- a/src/experiments/content-translation/utils/index.ts +++ b/src/experiments/content-translation/utils/index.ts @@ -2,6 +2,7 @@ * WordPress dependencies */ import type { Block } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -69,3 +70,21 @@ export function setTranslationLoadingClass( isLoading: boolean ) { isLoading ); } + +/** + * Get a user-friendly error message from an unknown error object. + * + * @param error The unknown error object to extract the message from. + * @return A string containing the error message to display to the user. + */ +export function getErrorMessage( error: unknown ): string { + if ( typeof error === 'string' ) { + return error; + } + + if ( error && typeof error === 'object' && 'message' in error ) { + return String( ( error as { message: string } ).message ); + } + + return __( 'Something went wrong. Please try again.', 'ai' ); +} From b5363cbfd80bc5d44c43e3232531ef85dab03dd4 Mon Sep 17 00:00:00 2001 From: yogeshbhutkar Date: Thu, 18 Jun 2026 14:32:43 +0530 Subject: [PATCH 05/23] Gate the experiment via settings --- .../components/ContentTranslationPlugin.tsx | 5 +++++ src/experiments/content-translation/constants.ts | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/experiments/content-translation/components/ContentTranslationPlugin.tsx b/src/experiments/content-translation/components/ContentTranslationPlugin.tsx index d800ab592..220eac674 100644 --- a/src/experiments/content-translation/components/ContentTranslationPlugin.tsx +++ b/src/experiments/content-translation/components/ContentTranslationPlugin.tsx @@ -13,6 +13,7 @@ import { useState } from '@wordpress/element'; import TranslationModal from './TranslationModal'; import { useContentTranslation } from '../hooks/useContentTranslation'; import { formatMinLengthLabel } from '../../../utils/word-count'; +import { getSettings } from '../utils'; export default function ContentTranslationPlugin() { const [ isOpen, setIsOpen ] = useState( false ); @@ -25,6 +26,10 @@ export default function ContentTranslationPlugin() { translate, } = useContentTranslation(); + if ( ! getSettings().enabled ) { + return null; + } + const buttonLabel = isTranslating ? sprintf( /* translators: %1$d: number of translated blocks, %2$d: total number of blocks */ diff --git a/src/experiments/content-translation/constants.ts b/src/experiments/content-translation/constants.ts index 517ffcfa2..4a0551588 100644 --- a/src/experiments/content-translation/constants.ts +++ b/src/experiments/content-translation/constants.ts @@ -19,5 +19,4 @@ export const TRANSLATION_BATCH_SIZE = 4; export const TRANSLATION_SUPPORTED_BLOCK_TYPES = [ 'core/paragraph', 'core/heading', - 'core/quote', ]; From 636f93359b669f5aa5d86288d6ea6173bbdd9884 Mon Sep 17 00:00:00 2001 From: yogeshbhutkar Date: Thu, 18 Jun 2026 14:59:16 +0530 Subject: [PATCH 06/23] Fix code quality --- .../Content_Translation.php | 19 ++-- .../Content_Translation/Languages.php | 91 ++++++------------- .../Content_Translation.php | 7 ++ 3 files changed, 46 insertions(+), 71 deletions(-) diff --git a/includes/Abilities/Content_Translation/Content_Translation.php b/includes/Abilities/Content_Translation/Content_Translation.php index 8c8ccb15a..fec96d462 100644 --- a/includes/Abilities/Content_Translation/Content_Translation.php +++ b/includes/Abilities/Content_Translation/Content_Translation.php @@ -1,6 +1,6 @@ get_prompt_builder( $prompt, $target_language ); + protected function generate_translated_content( string $prompt, string $language ) { + $builder = $this->get_prompt_builder( $prompt, $language ); if ( is_wp_error( $builder ) ) { return $builder; @@ -205,16 +210,16 @@ protected function generate_translated_content( string $prompt, string $target_l * @since x.x.x * * @param string $prompt The prompt to build. - * @param string $target_language The target language. + * @param string $language The target language. * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or a WP_Error if there isn't a model that supports text generation. */ - private function get_prompt_builder( string $prompt, string $target_language ) { + private function get_prompt_builder( string $prompt, string $language ) { $prompt_builder = wp_ai_client_prompt( $prompt ) ->using_system_instruction( $this->get_system_instruction( 'system-instruction.php', array( - 'target_language' => $target_language, + 'target_language' => $language, ) ) ) diff --git a/includes/Abilities/Content_Translation/Languages.php b/includes/Abilities/Content_Translation/Languages.php index e8e22ed91..5869ceb4e 100644 --- a/includes/Abilities/Content_Translation/Languages.php +++ b/includes/Abilities/Content_Translation/Languages.php @@ -9,6 +9,11 @@ namespace WordPress\AI\Abilities\Content_Translation; +/** + * Class providing supported languages for AI content translation. + * + * @since x.x.x + */ final class Languages { /** @@ -21,11 +26,11 @@ final class Languages { private const DEFAULT_TARGET_LANGUAGE = 'en-us'; /** - * The default target language for translation. + * Returns the default target language for translation. * * @since x.x.x * - * @var string + * @return string The default target language code. */ public static function get_default_target_language(): string { return self::DEFAULT_TARGET_LANGUAGE; @@ -36,72 +41,30 @@ public static function get_default_target_language(): string { * * @since x.x.x * - * @return array Supported languages. + * @return array Supported languages. */ public static function get_supported_languages(): array { $languages = array( - 'en-us' => array( - 'locale' => 'en_US', - 'name' => __( 'English (US)', 'ai' ), - ), - 'en-gb' => array( - 'locale' => 'en_GB', - 'name' => __( 'English (UK)', 'ai' ), - ), - 'es-es' => array( - 'locale' => 'es_ES', - 'name' => __( 'Spanish (Spain)', 'ai' ), - ), - 'fr-fr' => array( - 'locale' => 'fr_FR', - 'name' => __( 'French (France)', 'ai' ), - ), - 'de-de' => array( - 'locale' => 'de_DE', - 'name' => __( 'German', 'ai' ), - ), - 'it-it' => array( - 'locale' => 'it_IT', - 'name' => __( 'Italian', 'ai' ), - ), - 'pt-br' => array( - 'locale' => 'pt_BR', - 'name' => __( 'Portuguese (Brazil)', 'ai' ), - ), - 'nl-nl' => array( - 'locale' => 'nl_NL', - 'name' => __( 'Dutch', 'ai' ), - ), - 'ja' => array( - 'locale' => 'ja', - 'name' => __( 'Japanese', 'ai' ), - ), - 'zh-cn' => array( - 'locale' => 'zh_CN', - 'name' => __( 'Chinese (Simplified)', 'ai' ), - ), - 'zh-tw' => array( - 'locale' => 'zh_TW', - 'name' => __( 'Chinese (Traditional)', 'ai' ), - ), - 'ko' => array( - 'locale' => 'ko', - 'name' => __( 'Korean', 'ai' ), - ), - 'ar' => array( - 'locale' => 'ar', - 'name' => __( 'Arabic', 'ai' ), - ), - 'hi' => array( - 'locale' => 'hi', - 'name' => __( 'Hindi', 'ai' ), - ), + 'en-us' => __( 'English (US)', 'ai' ), + 'en-gb' => __( 'English (UK)', 'ai' ), + 'es-es' => __( 'Spanish (Spain)', 'ai' ), + 'fr-fr' => __( 'French (France)', 'ai' ), + 'de-de' => __( 'German', 'ai' ), + 'it-it' => __( 'Italian', 'ai' ), + 'pt-br' => __( 'Portuguese (Brazil)', 'ai' ), + 'nl-nl' => __( 'Dutch', 'ai' ), + 'ja' => __( 'Japanese', 'ai' ), + 'zh-cn' => __( 'Chinese (Simplified)', 'ai' ), + 'zh-tw' => __( 'Chinese (Traditional)', 'ai' ), + 'ko' => __( 'Korean', 'ai' ), + 'ar' => __( 'Arabic', 'ai' ), + 'hi' => __( 'Hindi', 'ai' ), ); /** * Filters supported target languages for AI content translation. * - * @param array $languages Supported languages. + * @param array $languages Supported languages. */ return (array) apply_filters( 'wpai_content_translation_languages', $languages ); } @@ -111,16 +74,16 @@ public static function get_supported_languages(): array { * * @since x.x.x * - * @return array Supported languages for JavaScript. + * @return array Supported languages for JavaScript. */ public static function get_supported_languages_for_js(): array { $languages = self::get_supported_languages(); return array_map( - static function ( string $code, array $language ): array { + static function ( string $code, string $name ): array { return array( 'code' => $code, - 'name' => $language['name'], + 'name' => $name, ); }, array_keys( $languages ), @@ -140,7 +103,7 @@ public static function get_language_name( string $language_code ): ?string { $languages = self::get_supported_languages(); if ( isset( $languages[ $language_code ] ) ) { - return $languages[ $language_code ]['name']; + return $languages[ $language_code ]; } return null; diff --git a/includes/Experiments/Content_Translation/Content_Translation.php b/includes/Experiments/Content_Translation/Content_Translation.php index 5bb9d4a7d..89abdffb3 100644 --- a/includes/Experiments/Content_Translation/Content_Translation.php +++ b/includes/Experiments/Content_Translation/Content_Translation.php @@ -72,6 +72,13 @@ public function register_abilities(): void { ); } + /** + * Enqueues and localizes the admin script. + * + * @since x.x.x + * + * @param string $hook_suffix The current admin page hook suffix. + */ public function enqueue_assets( string $hook_suffix ): void { // Enqueue assets only on the post editor screen. if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { From 869e52ea5872b2d6a90365f2879c921136909b68 Mon Sep 17 00:00:00 2001 From: yogeshbhutkar Date: Thu, 18 Jun 2026 15:19:38 +0530 Subject: [PATCH 07/23] Make the animations smooth --- src/experiments/content-translation/index.scss | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/experiments/content-translation/index.scss b/src/experiments/content-translation/index.scss index 2651a9af2..59d358e56 100644 --- a/src/experiments/content-translation/index.scss +++ b/src/experiments/content-translation/index.scss @@ -1,9 +1,9 @@ @keyframes ai-content-translation-text-shimmer { - 0% { - background-position: 180% 0; + from { + background-position: 300% 0; } - 100% { - background-position: -80% 0; + to { + background-position: 0 0; } } @@ -14,22 +14,20 @@ @media (prefers-reduced-motion: no-preference) { background: linear-gradient( 90deg, - var(--wpds-color-fg-content-neutral-weak, #707070) 30%, + var(--wpds-color-fg-content-neutral-weak, #707070) 42%, color-mix( in srgb, var(--wpds-color-fg-content-neutral-weak, #707070) 55%, #fff ) 50%, - var(--wpds-color-fg-content-neutral-weak, #707070) 70% + var(--wpds-color-fg-content-neutral-weak, #707070) 58% ); + background-size: 300% 100%; + animation: ai-content-translation-text-shimmer 1.8s linear infinite; background-clip: text; - background-size: 250% 100%; -webkit-background-clip: text; -webkit-text-fill-color: transparent; - animation: ai-content-translation-text-shimmer 1.1s linear infinite; - animation-delay: -0.35s; - animation-fill-mode: both; } } From c530d39c3c3a6451967f7c4dfdb42dd14c03865b Mon Sep 17 00:00:00 2001 From: yogeshbhutkar Date: Fri, 19 Jun 2026 12:50:29 +0530 Subject: [PATCH 08/23] Improve Code Quality, Logic flow, and UX --- .../components/TranslationModal.tsx | 28 +- .../content-translation/constants.ts | 10 +- .../hooks/useContentTranslation.ts | 248 +++++++++++++----- .../content-translation/index.scss | 5 +- .../content-translation/utils/index.ts | 44 +++- 5 files changed, 258 insertions(+), 77 deletions(-) diff --git a/src/experiments/content-translation/components/TranslationModal.tsx b/src/experiments/content-translation/components/TranslationModal.tsx index b4ce0f81c..3397a8bee 100644 --- a/src/experiments/content-translation/components/TranslationModal.tsx +++ b/src/experiments/content-translation/components/TranslationModal.tsx @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { Button, Modal, SelectControl } from '@wordpress/components'; +import { + Button, + Modal, + SelectControl, + ToggleControl, +} from '@wordpress/components'; import { useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Stack } from '@wordpress/ui'; @@ -14,7 +19,10 @@ import { getSettings } from '../utils'; type TranslationModalProps = { canTranslate: boolean; closeModal: () => void; - translate: ( languageCode: string ) => Promise< void >; + translate: ( + languageCode: string, + options?: { translateTitle?: boolean } + ) => Promise< void >; }; /** @@ -39,6 +47,7 @@ export default function TranslationModal( { [] ); + const [ translateTitle, setTranslateTitle ] = useState( false ); const [ selectedLanguage, setSelectedLanguage ] = useState( controls?.[ 0 ]?.value || '' ); @@ -47,6 +56,7 @@ export default function TranslationModal( { @@ -58,13 +68,25 @@ export default function TranslationModal( { __next40pxDefaultSize /> + setTranslateTitle( value ) } + checked={ translateTitle } + /> +