DEV Community

Cover image for Translating pages built with a page builder: shortcodes, block attributes, and the strings that never reach the content filter
AumCreate
AumCreate

Posted on

Translating pages built with a page builder: shortcodes, block attributes, and the strings that never reach the content filter

The report comes back as "the Portuguese page still has English on it". You open the translation screen, the body copy is fully translated, and the button in the hero still says "Request a quote".

That button text lives in an attribute of a shortcode, or a key inside a JSON blob in post meta, or a string in a theme option. Content filters never see any of those, so the translation layer never gets a chance.

What follows is how to find out where a given string actually lives, and what to do about each place.

Where builder text hides

Where the string lives What it looks like Does a content filter reach it?
Post content, as blocks <!-- wp:button {"text":"Request a quote"} --> It sees the rendered HTML, so text nodes sometimes, attribute-driven output never
Post content, as shortcodes [cta title="Request a quote"] No — attributes aren't content
Post meta builder layout saved as JSON, custom fields No
Options / theme mods header CTA, announcement bar, footer columns No
Taxonomies and menus nav labels, category names No — separate filters
Template files "Read more", form labels Yes, through gettext

The block row is the one people misread. A the_content filter sees HTML a block produced. Replacing a paragraph's text there can work. Replacing a button's label inside an <a> that CSS targets will break styling, and it can never fix alt, placeholder or a URL held in an attribute.

Inventory before translating anything

You need to know which pages use shortcodes, which use blocks, and which meta keys hold layout.

cd /path/to/wordpress

# content that contains shortcode syntax
wp db query "SELECT ID, post_title FROM wp_posts
  WHERE post_status='publish' AND post_type IN ('page','post')
  AND post_content LIKE '%[%' AND post_content LIKE '%]%'"

# content with block markup
wp db query "SELECT ID, post_title FROM wp_posts
  WHERE post_status='publish' AND post_type IN ('page','post')
  AND post_content LIKE '%<!-- wp:%'"
Enter fullscreen mode Exit fullscreen mode

Adjust wp_posts if your table prefix isn't wp_.

Which shortcodes are actually in use:

wp db query "SELECT post_content FROM wp_posts WHERE post_status='publish'" --skip-column-names \
  | grep -oE '\[[a-z0-9_-]+' | sort | uniq -c | sort -rn
Enter fullscreen mode Exit fullscreen mode

Meta keys that could hold layout:

wp db query "SELECT meta_key, COUNT(*) c FROM wp_postmeta
  GROUP BY meta_key ORDER BY c DESC LIMIT 40"
Enter fullscreen mode Exit fullscreen mode

A builder's layout typically appears as one meta key with a long serialized or JSON value. Look at the big ones:

wp db query "SELECT post_id, LENGTH(meta_value) len FROM wp_postmeta
  WHERE meta_key='_your_builder_layout' ORDER BY len DESC LIMIT 10"
Enter fullscreen mode Exit fullscreen mode

And options that render on every page:

wp option list --search='*footer*' --fields=option_name
wp option list --search='*header*' --fields=option_name
Enter fullscreen mode Exit fullscreen mode

That last query is often where the header CTA and the announcement bar live.

Shortcodes: catch the attribute where it's read

If you own the shortcode, translate inside the callback. It's the only place where you know which attribute is user-visible text and which one is a CSS class or a post ID.

add_shortcode( 'cta', function ( $atts, $content = '' ) {
    $atts = shortcode_atts(
        [ 'title' => '', 'button_text' => '', 'url' => '', 'style' => 'primary' ],
        $atts,
        'cta'
    );

    $title       = tr_string( $atts['title'] );
    $button_text = tr_string( $atts['button_text'] );

    return sprintf(
        '<div class="cta cta--%s"><h2>%s</h2><p>%s</p><a class="cta__btn" href="%s">%s</a></div>',
        esc_attr( $atts['style'] ),
        esc_html( $title ),
        wp_kses_post( do_shortcode( $content ) ),
        esc_url( $atts['url'] ),
        esc_html( $button_text )
    );
} );
Enter fullscreen mode Exit fullscreen mode

tr_string() is yours to define. A minimal version keyed on the request locale:

function tr_string( string $text ): string {
    static $map = null;

    if ( null === $map ) {
        $locale = determine_locale();
        $all    = [
            'de_DE' => [
                'Request a quote' => 'Angebot anfordern',
                'Talk to sales'   => 'Vertrieb kontaktieren',
            ],
            'pt_PT' => [
                'Request a quote' => 'Pedir orçamento',
                'Talk to sales'   => 'Falar com vendas',
            ],
        ];
        $map = $all[ $locale ] ?? [];
    }

    return $map[ $text ] ?? $text;
}
Enter fullscreen mode Exit fullscreen mode

determine_locale() is core, and it runs the locale filter — which is how whatever switches languages on your site usually announces its choice. So keying the map on it stays correct when you change the switching mechanism later.

If the shortcode comes from a theme or plugin you're not going to fork, you can intercept before the callback runs. pre_do_shortcode_tag gives you the parsed attributes and the matched content, and lets you return your own output:

add_filter( 'pre_do_shortcode_tag', function ( $return, $tag, $attr, $m ) {
    if ( false !== $return || empty( $attr ) || ! isset( $GLOBALS['shortcode_tags'][ $tag ] ) ) {
        return $return;
    }

    $eligible = apply_filters(
        'tr_shortcode_attr_keys',
        [ 'title', 'text', 'label', 'heading', 'subtitle', 'button_text', 'description' ],
        $tag
    );

    $translated = [];
    $changed    = false;

    foreach ( $attr as $key => $value ) {
        if ( ! in_array( $key, $eligible, true ) || ! is_string( $value ) ) {
            continue;
        }
        $new = tr_string( $value );
        if ( $new !== $value ) {
            $translated[ $key ] = $new;
            $changed            = true;
        }
    }

    if ( ! $changed ) {
        return $return;
    }

    $callback = $GLOBALS['shortcode_tags'][ $tag ];

    return call_user_func( $callback, $translated, $m[5] ?? '', $tag );
}, 10, 4 );
Enter fullscreen mode Exit fullscreen mode

Returning a non-false value short-circuits the original call, so the callback runs once, not twice — but it runs with a filtered attribute array. That's why the allowlist exists: without it, every string attribute goes through the map, including IDs, slugs and class names, and a map entry that happens to match one of those produces a very confusing render. Keep the map to sentences.

Blocks: change the attributes, not the HTML

render_block hands you finished HTML. render_block_data hands you the parsed block before rendering, which is where attributes are still mutable.

add_filter( 'render_block_data', function ( $parsed_block, $source_block, $parent_block ) {
    $name  = $parsed_block['blockName'] ?? '';
    $attrs = $parsed_block['attrs'] ?? [];

    $fields = [
        'core/button' => [ 'text' ],
        'core/image'  => [ 'alt' ],
    ];

    if ( ! isset( $fields[ $name ] ) ) {
        return $parsed_block;
    }

    foreach ( $fields[ $name ] as $key ) {
        if ( isset( $attrs[ $key ] ) && is_string( $attrs[ $key ] ) ) {
            $parsed_block['attrs'][ $key ] = tr_string( $attrs[ $key ] );
        }
    }

    return $parsed_block;
}, 10, 3 );
Enter fullscreen mode Exit fullscreen mode

Extend the $fields map per block by reading its block.json — that file lists every attribute the block stores, which is your authoritative list of translatable candidates. It also tells you which attributes are markup (skip) and which are user text (translate).

Note what this approach commits you to: the saved post content stays in the source language, and the translations live in your map. Editing copy means editing code or a map that editors can't see. The alternative is one post per locale, where nothing is invisible but structure has to be kept in sync and a layout change becomes N layout changes. Pick by who edits: if marketing rewrites copy weekly, separate posts per locale age better; if the copy is stable and the layout is what moves, render-time filtering is less work.

The dynamic bits that render on every page

Options carry more copy than people remember. The filter name is the option name:

add_filter( 'option_acme_announcement_bar', 'tr_string' );
add_filter( 'theme_mod_acme_footer_tagline', 'tr_string' );
Enter fullscreen mode Exit fullscreen mode

Menu labels live outside posts entirely:

add_filter( 'wp_setup_nav_menu_item', function ( $item ) {
    if ( is_admin() ) {
        return $item;
    }
    if ( isset( $item->title ) ) {
        $item->title = tr_string( $item->title );
    }
    return $item;
} );
Enter fullscreen mode Exit fullscreen mode

Term names — category archives, product categories — can be handled similarly:

add_filter( 'get_term', function ( $term ) {
    if ( is_admin() || ! $term instanceof WP_Term ) {
        return $term;
    }
    $term->name = tr_string( $term->name );
    return $term;
} );
Enter fullscreen mode Exit fullscreen mode

get_term() returns a clone, so mutating it stays inside that call. The catch is anything that already read and stored the name earlier in the same request — a menu item, a breadcrumb — won't reflect the change. If taxonomy names matter for search visibility, separate terms per locale with translated slugs are sturdier than a filter, because the slug is part of the URL.

Header, footer, and anything shared

A header built in a builder is usually a template post or a bundle of options. Translate it once, in place. If it's a synced pattern, translating it changes every page that uses it — normally what you want, and a mess if two people do it on the same afternoon.

Anything rendered from code — search result counts, 404 copy, form validation messages — comes from the .mo file and follows the locale, not your map. That's a different fix and a different person's job.

A leak check that runs in a loop

Eyeballing pages stops scaling around ten URLs.

cat > /tmp/urls.txt <<'EOF'
https://staging.example.com/
https://staging.example.com/pricing/
https://staging.example.com/contact/
EOF

while read -r url; do
  html=$(curl -sS "$url")
  for s in "Request a quote" "Talk to sales" "Read more" "Starts at"; do
    printf '%s' "$html" | grep -qF "$s" && echo "LEAK  $url  <- $s"
  done
done < /tmp/urls.txt
Enter fullscreen mode Exit fullscreen mode

Attributes are where the visual check fails, because a broken alt looks like nothing:

curl -sS https://staging.example.com/ \
  | grep -oE '(alt|placeholder|aria-label|title|value)="[^"]+"' \
  | sort -u
Enter fullscreen mode Exit fullscreen mode

And the parts machines read:

curl -sS https://staging.example.com/ | grep -oE '<html[^>]*lang="[^"]+"'
curl -sS https://staging.example.com/ | grep -oE '<link[^>]*hreflang="[^"]+"[^>]*>'
Enter fullscreen mode Exit fullscreen mode

The hreflang set should point at the translated URL and back at the source, and <html lang> should match the page it's on. A page rendering in German that declares lang="en" is a page you'll be redoing.


Most of the effort here is locating strings, not translating them. The switching side — locale handling, hreflang, the language files — is what our free plugin AumLang covers: https://aumcreate.com/plugins/aumlang?utm_source=devto&utm_medium=content&utm_campaign=devto-builder-pages-translate-shortcodes. The string-location work above applies either way.

Top comments (0)