References into the blocks. */ public array $refs = array(); /** @var string[] "html" or "text", per ref. */ public array $kinds = array(); /** @var string[] Where each text appears ("Button", "Hero heading"), per ref. */ public array $where = array(); /** * Collect every translatable string of $blocks (by reference). * * @param array> $blocks parse_blocks() output. */ public static function collect( array &$blocks ): self { $found = new self(); $found->skip = self::untranslated_blocks(); $found->walk( $blocks ); return $found; } /** @return string[] Attribute keys translated for this block; filter blocklingo_translatable_block_attributes. */ public static function attribute_keys( ?string $block_name ): array { return (array) apply_filters( 'blocklingo_translatable_block_attributes', self::ATTRIBUTE_KEYS, $block_name ); } /** Human name of a block for the model's notes ("Button", "Hero"). */ public static function label( ?string $name ): string { if ( ! $name ) { return 'Text'; } $type = \WP_Block_Type_Registry::get_instance()->get_registered( $name ); if ( $type && ! empty( $type->title ) ) { return (string) $type->title; } $slash = strpos( $name, '/' ); return ucfirst( str_replace( '-', ' ', false === $slash ? $name : substr( $name, $slash + 1 ) ) ); } /** * Blocks that are never translated: they hold a shortcode or point to a * form by id, and their "title" is a setting, not visible text. * * @return string[] */ public static function untranslated_blocks(): array { /** * Block names that machine translation leaves exactly as they are. * * @param string[] $names Default: the Shortcode block and the Contact Form 7 block. */ return array_values( array_map( 'strval', (array) apply_filters( 'blocklingo_untranslated_blocks', array( 'core/shortcode', 'contact-form-7/contact-form-selector' ) ) ) ); } /** Does the HTML contain any letters once tags and entities are gone? */ public static function has_text( string $html ): bool { $plain = html_entity_decode( wp_strip_all_tags( Shortcodes::strip( $html ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return (bool) preg_match( '/\p{L}/u', $plain ); } /** @return string[] Current values of the refs, in page order. */ public function texts(): array { return array_map( 'strval', $this->refs ); } /** @param array> $blocks */ private function walk( array &$blocks ): void { foreach ( array_keys( $blocks ) as $b ) { if ( in_array( $blocks[ $b ]['blockName'] ?? null, $this->skip, true ) ) { continue; // Shortcodes and form embeds: code and settings, not text. } $label = self::label( $blocks[ $b ]['blockName'] ?? null ); foreach ( (array) $blocks[ $b ]['innerContent'] as $i => $piece ) { if ( is_string( $piece ) && self::has_text( $piece ) ) { $this->refs[] = &$blocks[ $b ]['innerContent'][ $i ]; $this->kinds[] = 'html'; $this->where[] = $label; } } if ( ! empty( $blocks[ $b ]['attrs'] ) && is_array( $blocks[ $b ]['attrs'] ) ) { $allowed = self::attribute_keys( $blocks[ $b ]['blockName'] ?? null ); $this->walk_attrs( $blocks[ $b ]['attrs'], $allowed, null, $label ); } if ( ! empty( $blocks[ $b ]['innerBlocks'] ) ) { $this->walk( $blocks[ $b ]['innerBlocks'] ); } } } /** * @param array $attrs * @param string[] $allowed */ private function walk_attrs( array &$attrs, array $allowed, ?string $parent_key, string $label ): void { foreach ( array_keys( $attrs ) as $k ) { $key = is_int( $k ) ? $parent_key : (string) $k; if ( is_array( $attrs[ $k ] ) ) { $this->walk_attrs( $attrs[ $k ], $allowed, $key, $label ); continue; } if ( ! is_string( $attrs[ $k ] ) || ! in_array( $key, $allowed, true ) ) { continue; } $val = $attrs[ $k ]; if ( preg_match( '#^(https?:)?//|^/|^\#|^mailto:|^tel:#i', $val ) || ! self::has_text( $val ) ) { continue; } $this->refs[] = &$attrs[ $k ]; $this->kinds[] = ( str_contains( $val, '<' ) || str_contains( $val, '&' ) ) ? 'html' : 'text'; $this->where[] = $label . ' ' . $key; } } }