*/ public const LEAF_BLOCK_TAGS = array( 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'figcaption', 'td', 'th', 'dt', 'dd', 'caption', 'address', 'summary', 'hr', ); /** * Container block elements that group child elements. * * @var array */ public const CONTAINER_BLOCK_TAGS = array( 'div', 'section', 'article', 'aside', 'header', 'footer', 'nav', 'main', 'figure', 'ul', 'ol', 'dl', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'details', 'center', 'form', 'fieldset', 'blockquote', 'li', ); /** * HTML void / self-closing elements. * * @var array */ public const VOID_TAGS = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ); /** * Non-translatable elements whose inner content must be completely excluded from extraction. * * @var array */ public const EXCLUDED_TAGS = array( 'script', 'style', 'noscript', 'pre', 'svg', 'iframe', 'object', 'embed', 'textarea', 'select', 'option', 'template', 'canvas', 'audio', 'video', ); /** * HTML attributes that contain translatable human-readable text. * * @var array */ public const TRANSLATABLE_ATTRS = array( 'alt', 'title', 'aria-label', 'aria-description', 'placeholder', ); /** * Builder slug identifier. * * @return string */ public function get_builder_slug(): string { return 'classic'; } /** * Parse classic WordPress content into translatable string packages. * * Replaces whole-content extraction with DOM-based translation units. * * @param mixed $content Raw content string. * @param int $post_id Post ID if applicable. * @param string $lang Source language code. * @param array $rules Optional specific builder rules. * @return array Array of extracted string items. */ public function parse( $content, int $post_id = 0, string $lang = '', array $rules = array() ): array { if ( ! is_string( $content ) || '' === trim( $content ) ) { return array(); } $extracted = array(); $units = self::extract_units_with_offsets( $content, $rules ); foreach ( $units as $unit ) { $this->record_extracted_string( $post_id, $lang, $unit['name'], $unit['key'], $unit['value'], $extracted ); } return $extracted; } /** * Extract translation units from HTML content and map each to its exact source byte slice. * * Preserves inline elements inside blocks, respects wpautop blank-line paragraph breaks, * excludes non-translatable technical markup, and extracts standalone attributes separately. * * @param string $content Source HTML content. * @param array $rules Optional configuration rules. * @return array Array of mapped units with offsets, lengths, names, and values. */ public static function extract_units_with_offsets( string $content, array $rules = array() ): array { if ( '' === trim( $content ) ) { return array(); } // Fallback if PHP DOMDocument or DOMXPath extension is unavailable if ( ! class_exists( 'DOMDocument' ) || ! class_exists( 'DOMXPath' ) ) { return self::extract_fallback( $content ); } $pattern = '~||<(?:[^>"\']|"[^"]*"|\'[^\']*\')*>|[^<]+~s'; if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) { if ( self::has_translatable_text( $content ) ) { return array( array( 'type' => 'text', 'name' => 'classic_text', 'key' => 'post_content>text[0]', 'tag' => 'text', 'offset' => 0, 'length' => strlen( $content ), 'value' => $content, 'source' => $content, ), ); } return array(); } $tokens = $matches[0]; $units = array(); // Merge user-configured rules if provided $trans_attrs = self::TRANSLATABLE_ATTRS; if ( ! empty( $rules['attributes'] ) && is_array( $rules['attributes'] ) ) { $trans_attrs = array_unique( array_merge( $trans_attrs, array_map( 'strtolower', $rules['attributes'] ) ) ); } $excluded_tags = self::EXCLUDED_TAGS; if ( ! empty( $rules['exclude'] ) && is_array( $rules['exclude'] ) ) { $excluded_tags = array_unique( array_merge( $excluded_tags, array_map( 'strtolower', $rules['exclude'] ) ) ); } // 1. Parse tag boundaries and tree structure from source token stream $elements_by_tag = array(); $stack = array(); $auto_close = array( 'p', 'dt', 'dd', 'tr', 'td', 'th' ); foreach ( $tokens as $t ) { $raw = $t[0]; $offset = $t[1]; if ( '' === $raw || '<' !== $raw[0] || 0 === strpos( $raw, '||<(?:[^>"\']|"[^"]*"|\'[^\']*\')*>|[^<]+~s'; if ( ! preg_match_all( $pattern, $sub, $matches, PREG_OFFSET_CAPTURE ) ) { return false; } $tokens = $matches[0]; preg_match_all( $pattern, $target_trimmed, $t_m ); $target_tokens = $t_m[0]; $t_count = count( $target_tokens ); if ( 0 === $t_count || $t_count > count( $tokens ) ) { return false; } for ( $i = 0; $i <= count( $tokens ) - $t_count; $i++ ) { $match = true; for ( $j = 0; $j < $t_count; $j++ ) { $s_tok = $tokens[ $i + $j ][0]; $t_tok = $target_tokens[ $j ]; if ( '' !== $s_tok && '<' === $s_tok[0] ) { $s_tag_norm = preg_replace( '~\s*/\s*>$~', '>', strtolower( $s_tok ) ); $t_tag_norm = preg_replace( '~\s*/\s*>$~', '>', strtolower( $t_tok ) ); if ( $s_tag_norm !== $t_tag_norm ) { $match = false; break; } } else { $s_dec = trim( html_entity_decode( $s_tok, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); $t_dec = trim( html_entity_decode( $t_tok, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); if ( $s_dec !== $t_dec ) { $match = false; break; } } } if ( $match ) { $first_tok = $tokens[ $i ]; $last_tok = $tokens[ $i + $t_count - 1 ]; $start = $start_from + $first_tok[1]; $found_length = ( $last_tok[1] + strlen( $last_tok[0] ) ) - $first_tok[1]; return $start; } } return false; } /** * Safe regex-based fallback extractor when PHP DOMDocument extension is unavailable. * * Extracts individual block elements without returning the entire document as one string. * * @param string $content * @return array */ private static function extract_fallback( string $content ): array { $units = array(); $pattern = '/<(p|h[1-6]|li|blockquote|td|th)(?:\s+[^>]*)?>(.*?)<\/\1>/is'; if ( preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) { $tag_counts = array(); foreach ( $matches[0] as $i => $full_match ) { $tag = strtolower( $matches[1][ $i ][0] ); $inner = $matches[2][ $i ][0]; if ( self::has_translatable_text( $inner ) ) { $count = $tag_counts[ $tag ] ?? 0; $tag_counts[ $tag ] = $count + 1; $inner_offset = $full_match[1] + strpos( $full_match[0], $inner ); $units[] = array( 'type' => 'block', 'name' => 'classic_' . $tag, 'key' => 'post_content>' . $tag . '[' . $count . ']', 'tag' => $tag, 'offset' => $inner_offset, 'length' => strlen( $inner ), 'value' => trim( $inner ), 'source' => $inner, ); } } } return $units; } }