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:%'"
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
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"
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"
And options that render on every page:
wp option list --search='*footer*' --fields=option_name
wp option list --search='*header*' --fields=option_name
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 )
);
} );
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;
}
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 );
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 );
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' );
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;
} );
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;
} );
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
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
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="[^"]+"[^>]*>'
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)