]*>|\s*|\s+)+$~', $value, $match, PREG_OFFSET_CAPTURE ) && $match[0][1] > 0 ) { return substr( $value, 0, $match[0][1] ) . $marker . substr( $value, $match[0][1] ); } return $value . $marker; } /** * Walks a stored value down to its string leaves and returns it rebuilt from what the callback * returns. Arrays and objects keep their shape. A string that holds JSON or shortcodes comes back * untouched when nothing inside it changed. * * @param mixed $value Stored value: string, array or object. * @param callable $callback Receives ( string $leaf, string $path, string $rule ) and returns the new leaf. * @param string $path Path of the value inside its object, e.g. "meta/_elementor_data". * @param string $source Where the value comes from, for rule keys, e.g. "meta:_elementor_data". * @return mixed */ function swiftling_walk( $value, $callback, $path, $source ) { return swiftling_walk_node( $value, $callback, $path, $source, '', '', true ); } /** * Walks one node of a stored value. * * @param mixed $value Node. * @param callable $callback Leaf callback. * @param string $path Path of the node. * @param string $source Where the value comes from. * @param string $type Nearest element type above the node. * @param string $rel Path from that element to the node, with list positions as "*". * @param bool $is_root Whether the node is the value itself. * @return mixed */ function swiftling_walk_node( $value, $callback, $path, $source, $type, $rel, $is_root ) { if ( is_string( $value ) ) { return swiftling_walk_string( $value, $callback, $path, $source, $type, $rel ); } if ( ! is_array( $value ) && ! $value instanceof stdClass ) { return $value; } $is_object = is_object( $value ); $items = $is_object ? get_object_vars( $value ) : $value; if ( ! $is_root ) { $own_type = swiftling_element_type( $items ); if ( '' !== $own_type ) { $type = $own_type; $rel = ''; } } $changed = false; foreach ( $items as $key => $item ) { if ( ! is_string( $item ) && ! is_array( $item ) && ! $item instanceof stdClass ) { continue; } $segment = is_int( $key ) ? swiftling_list_segment( $key, $item ) : swiftling_escape_segment( (string) $key ); $new = swiftling_walk_node( $item, $callback, $path . '/' . $segment, $source, $type, ltrim( $rel . '/' . ( is_int( $key ) ? '*' : swiftling_escape_segment( (string) $key ) ), '/' ), false ); if ( $new !== $item ) { $items[ $key ] = $new; $changed = true; } } if ( ! $changed ) { return $value; } return $is_object ? (object) $items : $items; } /** * Walks a string: JSON and shortcodes inside it are decoded, anything else is a leaf. * * @param string $value String. * @param callable $callback Leaf callback. * @param string $path Path of the string. * @param string $source Where the value comes from. * @param string $type Nearest element type above the string. * @param string $rel Path from that element to the string. * @return string */ function swiftling_walk_string( $value, $callback, $path, $source, $type, $rel ) { // A value the user chose as one text on the Themes & plugins screen, such as a tracking script // in a plugin's settings, is one leaf: what it holds is never split into JSON, shortcodes or // attributes, so its translation replaces it as a whole. if ( \SwiftLing\Packages\Settings::isWhole( $path ) ) { return call_user_func( $callback, $value, $path, swiftling_rule_key( $source, $type, $rel ) ); } $trimmed = ltrim( $value ); if ( '' !== $trimmed && ( '[' === $trimmed[0] || '{' === $trimmed[0] ) ) { $decoded = json_decode( $value ); if ( is_array( $decoded ) || is_object( $decoded ) ) { $new = swiftling_walk_node( $decoded, $callback, $path . '/@json', $source, $type, $rel, false ); return $new === $decoded ? $value : wp_json_encode( $new ); } } $encoded = swiftling_decode_base64_json( $trimmed ); if ( null !== $encoded ) { $new = swiftling_walk_node( $encoded, $callback, $path . '/@b64', $source, $type, $rel, false ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Writes back what the builder stored as base64 JSON. return $new === $encoded ? $value : base64_encode( wp_json_encode( $new ) ); } $with_brizy = swiftling_walk_brizy_texts( $value, $callback, $path, $source, $type, $rel ); if ( null !== $with_brizy ) { return $with_brizy; } $with_shortcodes = swiftling_walk_shortcodes( $value, $callback, $path, $source ); if ( null !== $with_shortcodes ) { return $with_shortcodes; } $with_attributes = swiftling_walk_html_attributes( $value, $callback, $path, $source, $type, $rel ); if ( null !== $with_attributes ) { return $with_attributes; } return call_user_func( $callback, $value, $path, swiftling_rule_key( $source, $type, $rel ) ); } /** * Returns the JSON a string holds as base64, which is how Brizy keeps what it prints and what * its editor saves, in the post meta "brizy-compiled-sections" and in "editor_data". * * Only a string that is base64 all through and decodes to a JSON array or object counts, so a * word or a code that happens to be valid base64 is no JSON and stays a leaf. * * @param string $value String, without leading space. * @return array|stdClass|null The decoded JSON, or null when the string holds none. */ function swiftling_decode_base64_json( $value ) { if ( strlen( $value ) < 16 || 1 !== preg_match( '~^[A-Za-z0-9+/]+={0,2}$~', $value ) ) { return null; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Reads what the builder stored as base64 JSON. $json = base64_decode( $value, true ); if ( false === $json || '' === $json || ( '{' !== $json[0] && '[' !== $json[0] ) ) { return null; } $decoded = json_decode( $json ); return is_array( $decoded ) || is_object( $decoded ) ? $decoded : null; } /** * Walks the HTML Brizy prints for a page, text by text: * * - every element it marks with data-brz-translate-text is a leaf, the HTML inside it; * - the title of a tab on a small screen, which Brizy prints a second time without the mark, is * the leaf of the same title on a large screen, so the two are one text; * - every attribute it names data-brz-translatable-*, such as the message of a countdown, is a leaf. * * The rest of the section, its classes, its layout and its scripts, stays as it is, so a * translation cannot break what Brizy built. * * @param string $value String. * @param callable $callback Leaf callback. * @param string $path Path of the string. * @param string $source Where the value comes from. * @param string $type Nearest element type above the string. * @param string $rel Path from that element to the string. * @return string|null The rebuilt string, or null when it holds no text Brizy marked. */ function swiftling_walk_brizy_texts( $value, $callback, $path, $source, $type, $rel ) { if ( false === strpos( $value, 'data-brz-translat' ) ) { return null; } $paths = array(); $marked = swiftling_walk_brizy_marked( $value, $callback, $path, $source, $type, $rel, $paths ); $current = null === $marked ? $value : $marked; $tabs = swiftling_walk_brizy_mobile_tabs( $current, $callback, $path, $source, $type, $rel, $paths ); $current = null === $tabs ? $current : $tabs; $attributes = swiftling_walk_brizy_attributes( $current, $callback, $path, $source, $type, $rel ); $current = null === $attributes ? $current : $attributes; return null === $marked && null === $tabs && null === $attributes ? null : $current; } /** * Walks the elements Brizy marks with data-brz-translate-text, see swiftling_walk_brizy_texts(). * * @param string $value String. * @param callable $callback Leaf callback. * @param string $path Path of the string. * @param string $source Where the value comes from. * @param string $type Nearest element type above the string. * @param string $rel Path from that element to the string. * @param string[] $paths Receives the path of each text, by its HTML. * @return string|null The rebuilt string, or null when it holds no such element. */ function swiftling_walk_brizy_marked( $value, $callback, $path, $source, $type, $rel, array &$paths ) { if ( ! preg_match_all( '~<(div|span)\b[^>]*\sdata-brz-translate-text="1"[^>]*>~i', $value, $opens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return null; } $result = ''; $offset = 0; $changed = false; $index = 0; foreach ( $opens as $open ) { $start = $open[0][1] + strlen( $open[0][0] ); if ( $open[0][1] < $offset ) { continue; } // The closing tag that matches the opening one: the same tag opens and closes inside it too. $tag = strtolower( $open[1][0] ); $depth = 1; $end = null; $from = $start; while ( preg_match( '~<(/?)' . $tag . '\b[^>]*>~i', $value, $tag_match, PREG_OFFSET_CAPTURE, $from ) ) { $from = $tag_match[0][1] + strlen( $tag_match[0][0] ); if ( '' !== $tag_match[1][0] ) { --$depth; } elseif ( '/>' !== substr( $tag_match[0][0], -2 ) ) { ++$depth; } if ( 0 === $depth ) { $end = $tag_match[0][1]; break; } } if ( null === $end ) { continue; } $inner = substr( $value, $start, $end - $start ); if ( '' === swiftling_visible_text( $inner ) ) { continue; } $text_path = $path . '/@bz' . $index; if ( ! isset( $paths[ $inner ] ) ) { $paths[ $inner ] = $text_path; } $new = call_user_func( $callback, $inner, $text_path, swiftling_rule_key( $source, $type, ltrim( $rel . '/@brizy', '/' ) ) ); ++$index; if ( $new === $inner ) { continue; } $result .= substr( $value, $offset, $start - $offset ) . $new; $offset = $end; $changed = true; } return $changed ? $result . substr( $value, $offset ) : ( $index > 0 ? $value : null ); } /** * Walks the titles of tabs Brizy prints for small screens, see swiftling_walk_brizy_texts(). A title * is the leaf of the title with the same HTML on large screens, when there is one, so its * translation is the same text; a title that has no twin is a leaf of its own. * * @param string $value String. * @param callable $callback Leaf callback. * @param string $path Path of the string. * @param string $source Where the value comes from. * @param string $type Nearest element type above the string. * @param string $rel Path from that element to the string. * @param string[] $paths Path of each marked text, by its HTML. * @return string|null The rebuilt string, or null when it holds no such title. */ function swiftling_walk_brizy_mobile_tabs( $value, $callback, $path, $source, $type, $rel, array $paths ) { if ( false === strpos( $value, 'brz-tabs__nav--mobile' ) || ! preg_match_all( '~(
)([^<]+)()~', $value, $titles, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return null; } $result = ''; $offset = 0; $changed = false; $index = 0; foreach ( $titles as $title ) { $inner = $title[2][0]; if ( '' === swiftling_visible_text( $inner ) ) { continue; } $start = $title[2][1]; $new = call_user_func( $callback, $inner, $paths[ $inner ] ?? $path . '/@bztab' . $index, swiftling_rule_key( $source, $type, ltrim( $rel . '/@brizy', '/' ) ) ); ++$index; if ( $new === $inner ) { continue; } $result .= substr( $value, $offset, $start - $offset ) . $new; $offset = $start + strlen( $inner ); $changed = true; } return $changed ? $result . substr( $value, $offset ) : ( $index > 0 ? $value : null ); } /** * Walks the attributes Brizy names data-brz-translatable-*, see swiftling_walk_brizy_texts(). * * @param string $value String. * @param callable $callback Leaf callback. * @param string $path Path of the string. * @param string $source Where the value comes from. * @param string $type Nearest element type above the string. * @param string $rel Path from that element to the string. * @return string|null The rebuilt string, or null when it holds no such attribute. */ function swiftling_walk_brizy_attributes( $value, $callback, $path, $source, $type, $rel ) { if ( ! preg_match_all( '~\sdata-brz-translatable-([a-z0-9-]+)="([^"]*)"~i', $value, $attributes, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return null; } $result = ''; $offset = 0; $changed = false; $index = 0; foreach ( $attributes as $attribute ) { $text = html_entity_decode( $attribute[2][0], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); if ( '' === trim( $text ) ) { continue; } $new = call_user_func( $callback, $text, $path . '/@bza' . $index, swiftling_rule_key( $source, $type, ltrim( $rel . '/@brizy-attribute', '/' ) ) ); ++$index; if ( $new === $text ) { continue; } $start = $attribute[2][1]; $result .= substr( $value, $offset, $start - $offset ) . esc_attr( $new ); $offset = $start + strlen( $attribute[2][0] ); $changed = true; } return $changed ? $result . substr( $value, $offset ) : ( $index > 0 ? $value : null ); } /** * Walks HTML without visible text whose text is in attributes: the alt text of an image, a title * or a label, as in the HTML a cover or image block saves around its image. Such HTML is no text * as a whole, so each of these attributes is a leaf of its own; the rest of the HTML stays as it is. * So is the address of an image, a video or audio, and of a link around them (src and href), so a * language can show another image or link elsewhere. * * Text attributes and addresses are numbered apart in their paths, as alt0, src0: the paths of * text attributes stay as they were before addresses became leaves, and so do their translations. * * HTML with visible text is a leaf as a whole, and its attributes are translated with it. * * @param string $value String. * @param callable $callback Leaf callback. * @param string $path Path of the string. * @param string $source Where the value comes from. * @param string $type Nearest element type above the string. * @param string $rel Path from that element to the string. * @return string|null The rebuilt string, or null when it is not HTML with text only in such attributes. */ function swiftling_walk_html_attributes( $value, $callback, $path, $source, $type, $rel ) { if ( false === strpos( $value, '<' ) || '' !== swiftling_visible_text( $value ) ) { return null; } if ( ! preg_match_all( '~(\s(alt|title|aria-label|src|href)=)(["\'])([^"\']*)\3~i', $value, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return null; } $result = ''; $offset = 0; $changed = false; $counts = array( 'text' => 0, 'address' => 0, ); foreach ( $matches as $match ) { $text = html_entity_decode( $match[4][0], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $name = strtolower( $match[2][0] ); $group = in_array( $name, array( 'src', 'href' ), true ) ? 'address' : 'text'; // Only addresses visitors see or follow; a marker in the address of a script or a style // would keep it from loading while the page is checked. if ( 'address' === $group && ! in_array( swiftling_html_tag_at( $value, $match[0][1] ), array( 'img', 'video', 'audio', 'source', 'a', 'iframe' ), true ) ) { continue; } $index = $counts[ $group ]++; $new = '' === trim( $text ) ? $text : call_user_func( $callback, $text, $path . '/@' . $name . $index, swiftling_rule_key( $source, $type, ltrim( $rel . '/@' . $name, '/' ) ) ); if ( $new === $text ) { continue; } $start = $match[4][1]; $result .= substr( $value, $offset, $start - $offset ) . esc_attr( $new ); $offset = $start + strlen( $match[4][0] ); $changed = true; } return $changed ? $result . substr( $value, $offset ) : $value; } /** * Returns the name of the tag a position in HTML is inside, such as "img" for an attribute of an image. * * @param string $html HTML. * @param int $position Byte offset. * @return string Tag name in lower case, or an empty string outside a tag. */ function swiftling_html_tag_at( $html, $position ) { return 1 === preg_match( '~<([a-z][a-z0-9-]*)\b[^<>]*$~i', substr( $html, 0, $position ), $found ) ? strtolower( $found[1] ) : ''; } /** * Walks a string that contains registered shortcodes. The text between shortcodes, every * attribute and every enclosed content become leaves; a shortcode is written again only when * something inside it changed. * * @param string $value String. * @param callable $callback Leaf callback. * @param string $path Path of the string. * @param string $source Where the value comes from. * @return string|null The rebuilt string, or null when the string holds no registered shortcode. */ function swiftling_walk_shortcodes( $value, $callback, $path, $source ) { global $shortcode_tags; if ( empty( $shortcode_tags ) || false === strpos( $value, '[' ) ) { return null; } $pattern = get_shortcode_regex(); if ( ! preg_match_all( "/$pattern/", $value, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return null; } $result = ''; $offset = 0; $changed = false; foreach ( $matches as $index => $match ) { $whole = $match[0][0]; $start = $match[0][1]; // Text before this shortcode. $before = substr( $value, $offset, $start - $offset ); $result .= swiftling_walk_text_segment( $before, $callback, $path . '/@t' . $index, $source, $changed ); $offset = $start + strlen( $whole ); // [[tag]] is an escaped shortcode, shown literally. if ( '[' === $match[1][0] && ']' === $match[6][0] ) { $result .= $whole; continue; } $tag = $match[2][0]; $attrs = shortcode_parse_atts( $match[3][0] ); $attrs = is_array( $attrs ) ? $attrs : array(); $base = $path . '/@sc' . $index; $new_attrs = array(); foreach ( $attrs as $name => $attr ) { // An attribute that names what the shortcode has to find is no leaf: a marker in it would // keep the shortcode from finding it, and the page would show nothing of it. if ( ! is_string( $attr ) || ( is_string( $name ) && swiftling_skip_shortcode_attribute( $name ) ) ) { $new_attrs[ $name ] = $attr; continue; } $new_attrs[ $name ] = swiftling_walk_string( $attr, $callback, $base . '/' . swiftling_escape_segment( (string) $name ), $source, $tag, 'attr/' . ( is_int( $name ) ? '*' : $name ) ); } $content = $match[5][0]; $has_closing = false !== strpos( $whole, '[/' . $tag . ']' ); $new_content = '' === $content ? '' : swiftling_walk_string( $content, $callback, $base . '/content', $source, $tag, 'content' ); if ( $new_attrs === $attrs && $new_content === $content ) { $result .= $whole; continue; } $changed = true; $result .= '[' . $tag; foreach ( $new_attrs as $name => $attr ) { $attr = str_replace( '"', '"', (string) $attr ); $result .= is_int( $name ) ? ' "' . $attr . '"' : ' ' . $name . '="' . $attr . '"'; } $result .= ( $has_closing ? ']' . $new_content . '[/' . $tag . ']' : ( '/' === $match[4][0] ? ' /]' : ']' ) ); } $result .= swiftling_walk_text_segment( substr( $value, $offset ), $callback, $path . '/@t' . count( $matches ), $source, $changed ); return $changed ? $result : $value; } /** * Whether an attribute of a shortcode names what the shortcode has to find, instead of holding * text a visitor reads: the id of a form or a post, a list of ids, a key, a slug or a hash, and * the class or the style the theme matches. Such a value is left as it is, as the address of a * script or a style is in swiftling_walk_html_attributes(): a marker in it keeps the shortcode * from finding what it names, so the page a check looks at shows nothing of it, and the check * finds none of its texts. * * Attributes that hold text, such as a title or a label, stay leaves. A value that only looks * like a setting is left alone by swiftling_is_text_like() anyway, also inside a block, where what * the block saved between its tags counts as text but the attributes of a shortcode only by look, * see swiftling_is_declared_text(). This rule is about the names: an identifier can look like a * word, as a form named "contact". * * @param string $name Attribute name, as shortcode_parse_atts() returns it, in lower case. * @return bool */ function swiftling_skip_shortcode_attribute( $name ) { return 1 === preg_match( '/(?:^|[_-])(?:ids?|keys?|slugs?|hash|uid|uuid|guid|class|style)$/i', $name ); } /** * Walks the text between shortcodes. * * @param string $text Text segment. * @param callable $callback Leaf callback. * @param string $path Path of the segment. * @param string $source Where the value comes from. * @param bool $changed Set to true when the segment changed. * @return string */ function swiftling_walk_text_segment( $text, $callback, $path, $source, &$changed ) { if ( '' === trim( $text ) ) { return $text; } $new = call_user_func( $callback, $text, $path, swiftling_rule_key( $source, 'shortcode-text', '' ) ); if ( $new !== $text ) { $changed = true; } return $new; } /** * Returns the type of an element in a builder's data, when the node carries one. * * Builders name the kind of each element in a field next to its settings: "widgetType" in * Elementor, "blockName" in blocks, "type" in Beaver Builder settings, "name" in Bricks. The type * only groups what the probe learns; the probe works without it. * * @param array $node Array or object properties. * @return string */ function swiftling_element_type( $node ) { foreach ( array( 'widgetType', 'blockName', 'elType', 'type', 'name' ) as $key ) { if ( isset( $node[ $key ] ) && is_string( $node[ $key ] ) && preg_match( '~^[a-z][a-z0-9_\-/]{0,60}$~i', $node[ $key ] ) ) { return $node[ $key ]; } } return ''; } /** * Returns the path segment of a list item: its id when it has one, so the path survives * reordering, otherwise its position. * * @param int $key Position. * @param mixed $item List item. * @return string */ function swiftling_list_segment( $key, $item ) { $props = is_object( $item ) ? get_object_vars( $item ) : $item; if ( is_array( $props ) ) { foreach ( array( 'id', '_id' ) as $id_key ) { if ( isset( $props[ $id_key ] ) && ( is_string( $props[ $id_key ] ) || is_int( $props[ $id_key ] ) ) && '' !== (string) $props[ $id_key ] ) { return '#' . swiftling_escape_segment( (string) $props[ $id_key ] ); } } } return (string) $key; } /** * Escapes a key for use in a path. * * @param string $key Key. * @return string */ function swiftling_escape_segment( $key ) { return str_replace( array( '%', '/' ), array( '%25', '%2F' ), $key ); } /** * Builds a rule key, shortened with a hash when it would not fit the rules table. * * @param string $source Where the value comes from. * @param string $type Element type. * @param string $rel Path from the element to the leaf. * @return string */ function swiftling_rule_key( $source, $type, $rel ) { $key = $source . '|' . $type . '|' . $rel; return strlen( $key ) > 191 ? substr( $key, 0, 150 ) . '|' . md5( $key ) : $key; }