Vizou Bling

Tabbed category

A cut gemstone on pink background

Photo ©Mohamed Nohassi

Vizou Bling (short for “bilingual”) is a lightweight WordPress plugin initially designed for my own use developing custom, bilingual themes in English and French, created with artists, freelancers and other small businesses in mind. The goal of this plugin was to avoid the complexity of currently popular, large-scale plugins that have a steep learning curve and require constant synchronization of bilingual content elements.

This plugin does not maintain separate directories for each language, part of what can make more complex plugins hard to use. All content is contained in a each Post or Page’s editing area (see more details about SEO considerations in the Use tab above). A language switcher can be added anywhere in your theme to quickly swap out language content. Requires any version of the Advanced Custom Fields (ACF) plugin to be installed and activated.

This plugin will never be available in the WordPress repository, but is, of course, open source and free to anyone who wants to use or adapt it (it can quite easily be converted to other language pairs).

Features

Please note that this plugin is made primarily for use by theme developers designing custom themes from scratch. It is not intended to be compatible with existing themes or theme builders (yuck!) and is not available in the WordPress repository.

  • Create bilingual  content for Posts and Pages in your WordPress site
  • Built-in, customizable language switcher  (button, text, etc.) with Settings options
  • Fast switching, with a single click, between language content (already loaded in each Page or Post)
  • Custom Title, Content and Excerpt fields (built with ACF), on the same Post or Page for easy editing
  • Featured images are shared between both languages for both Posts and Pages
  • Secondary language content URLs take the primary language page slug and add the prefix /?lang= (i.e. /?lang=fr) to enable direct linking and sharing
  • Help page in WordPress admin (currently English only)
  • Open source and free to anyone who wants to use, fork or adapt it
  • Auto-update available from the WordPress admin plugins page

Requirements

  • Advanced Custom Fields (ACF) plugin must be installed and activated on your site (free or paid version).
  • You will need to customize your theme’s page templates (examples on the Help page) to use plugin functionality

Considerations

  • By default, secondary language Pages and Posts share the primary language page slug (the content is created and edited on the same page). To provide a direct, shareable link, secondary language URLs  automatically have a suffix added to that slug, for example example.com/about-the-artist/?lang=fr .
  • Single-language URLs may be a potential issue with SEO for some types of sites, however, the primary language remains SEO-friendly and all language content is contained and exposed to search bots in a single Page or Post rendering it indexable by search bots.

Installation

  • Download the plugin from the Download tab above
  • Visit the plugins page in the WordPress admin area and upload the zipped plugin (latest version available from the Download tab)
  • Visit Settings / Vizou Bling to set your language switcher display options (primary and secondary languages)
  • Visit the Help tab at Settings / Vizou Bling to learn how to customize your theme’s templates (category, page, etc.)
  • Be sure the Advanced Custom Fields plugin (any version) is installed and activated on your site!

Theming notes

  • By default, secondary language Pages and Posts share the primary language page slug (the content is created and edited on the same page). To provide a direct, shareable link, secondary language URLs  automatically have a suffix added to that slug, for example example.com/about-the-artist/?lang=fr .
  • Single-language URLs may be a potential issue with SEO for some types of sites, however, the primary language (usually the target audience) remains SEO-friendly and all language content is contained and exposed and indexable by search bots in a single Page or Post. This plugin might not be suitable for those who require highly-optimized SEO for both languages.
  • Please note that this plugin is not built for compatibility with site block builders (Gutenberg, Elementor, etc.) because I can’t stand them. But it might work fine. Just saying.
  • Coming shortly! Download some sample theme template files to better understand how to incorporate the following into your theme.

Plugin documentation

1. The two things the plugin actually requires

Everything else in this guide is a suggestion. Only two things are load-bearing:

  • Secondary language content lives in ACF fields — For example, french_page on Pages, french_post on Posts. Each is a group with title and content sub-fields (posts also get excerpt). Primary language content lives in WordPress’s own native title/content/excerpt fields — nothing plugin-specific there.
  • vizou_bling_current_language() tells you which language the visitor is currently in (i.e. 'en' or 'fr' ). Call it whenever a template needs to know.

The sample theme (in this case, Débutante) is configured with English as the primary (WP-native) language and French as the secondary (ACF-held) language. That assignment is set once, in language-config.php, when the plugin is set up for a project — it isn’t something a template needs to think about, and it isn’t meant to change afterward.

A note on the language names and codes used throughout this page: “English”, “French”, “en”, and “fr” are this project’s current configured values, pulled live from language-config.php — they aren’t fixed plugin vocabulary. A different project running this same plugin (forked for, say, English/Spanish) would see “Spanish” and “es” in their place everywhere on this page, including in the ACF field names and CSS classes referenced below. Nothing about the specific words matters to the plugin itself — only that whatever values are set in language-config.php are used consistently across the theme.

2. Minimal pattern: check for secondary language content, fall back to primary

This is the core pattern used throughout the site’s templates. It works with any layout — no specific markup required:

$secondary = get_field('french_page', $post_id);
$has_secondary = isset($secondary['title']) && !empty($secondary['title']);

if (!$has_secondary) {
    // Only primary-language content exists — render it, regardless of current language.
    echo $title; // primary-language title (native WP field)
    echo $content;
} else {
    // Both languages exist. Render whichever the visitor is currently viewing.
    if (vizou_bling_current_language() === 'fr') {
        echo $secondary['title'];
        echo $secondary['content'];
    } else {
        echo $title; // primary-language title (native WP field)
        echo $content;
    }
}

This alone is enough to build a working bilingual template. Everything below is about making the switch happen without a page reload, which is optional but is what the rest of the site does.

3. A note on caching

If the template branches server-side on vizou_bling_current_language() the way the snippet above does, the rendered HTML will reflect whichever visitor’s cookie was active when a caching layer (Cloudflare or otherwise) last generated that page — every subsequent visitor gets that same version until the cache expires, regardless of their own language preference.

If that matters for a given template, render both languages into the page and use CSS/JS to show only the relevant one, rather than branching in PHP. That’s what the optional JS in section 4 is built to do.

4. Optional: instant client-side switching (no reload)

The plugin bundles a small set of scripts that toggle visibility between two rendered language blocks based on a body class, so a visitor can switch languages instantly without a fresh page request. This is entirely optional — it expects a markup convention, not something the plugin enforces:

<div id="tab-en"> English content here </div>
<div id="tab-fr"> French content here </div>

Both blocks render every time; the bundled JS shows/hides them based on the lang-en / lang-fr class it sets on <body>. If a template doesn’t use this convention, that’s fine — it just won’t get instant switching from the bundled JS, and would need its own toggle logic (or simply reload the page, which the minimal pattern in section 2 already supports via the ?lang= URL parameter).

For a working example, look at how page.php and category.php in the theme are built — they’re one implementation of this convention, not a required template. (This reference assumes the Vizou-built theme this plugin was originally paired with. Building a new theme from scratch instead? There’s nothing to look at yet — just follow the tab-en/tab-fr pattern described above directly.)

5. Visitor-facing text (“Read more”, the language switcher label)

Strings like the switcher label or a “read more” link don’t need to be hardcoded per-language in a template. Call:

vizou_bling_string('read_more', vizou_bling_current_language());
vizou_bling_string('switcher_label', vizou_bling_current_language());

This resolves in order: a site-wide value set on the Vizou Bling → Settings page, then a built-in default, then the vizou_bling_string filter — so if one specific template needs different wording than the site default (a landing page that wants “See details →” instead of “Read more”, for example), hook the filter locally in that template rather than changing the site-wide setting:

add_filter('vizou_bling_string', function ($value, $key, $lang) {
    if ($key === 'read_more' && $lang === 'en') {
        return 'See details →';
    }
    return $value;
}, 10, 3);

Both strings can also hold a narrow set of inline HTML instead of plain text — currently just <i> and <span> tags with a class attribute, enough for an icon-font glyph (Font Awesome or similar) in place of the text label. Anything outside that allow-list — links, buttons, inline styles, event-handler attributes — is stripped by wp_kses(), both when the Settings value is saved and every time vizou_bling_string() resolves a value afterward, regardless of whether it came from the default, the Settings option, or a filter.

Because of that, don’t wrap vizou_bling_string()‘s return value in esc_html() when echoing it — the value is already sanitized to a safe subset, and esc_html() would escape any icon markup back into visible text (&lt;i class="fa-solid fa-globe"&gt;) instead of letting it render.

6. Optional: the language switcher control

The plugin doesn’t render a switcher for you — the toggle itself is left entirely to the theme. But if you want a switcher to actually work with the bundled JS, two things about it are load-bearing:

  • lang-switch class on the clickable element — required. langswitch.js binds its click handler to .lang-switch via a delegated event, so anything carrying that class anywhere on the page will trigger a language switch.
  • current-lang class on whatever element holds the visible label text — required. After a switch, the same script rewrites the text inside every .current-lang element to match the new language.

Everything else — the wrapping tag, whether it’s a <li>, a <span>, or something else, and where it sits on the page — is just one implementation choice, same as the tab-en/tab-fr convention in section 4. Wrap it in whatever suits the context: a <li> if it lives inside a <ul> nav, a <span> if it’s dropped inline elsewhere.

<a href="#" class="lang-switch">
    <span class="current-lang"><?php echo vizou_bling_string('switcher_label', vizou_bling_current_language()); ?></span>
</a>

Note this deliberately does not wrap the call in esc_html() — see the callout at the end of section 5 for why.

One sync gotcha worth knowing: the label text is set from two different places, and they need to agree. On first page load, the PHP above calls vizou_bling_string() directly. After a click, langswitch.js instead rewrites .current-lang from VizouBlingConfig.strings.switcherLabel — a snapshot of those same values, taken once and localized in assets.php at page-load time, not called live. The bundled JS uses .html() rather than .text() for this update, so icon markup renders correctly on the client side too. If a per-template vizou_bling_string filter (section 5) changes the label in a way that snapshot doesn’t reflect, the switcher can show the “right” label on load and a stale one after switching, or the reverse. Keep any filter logic consistent regardless of when in the request it runs, or expect a mismatch.

For a working example, see content-nav.php in the theme. (As in section 4, that’s a reference to the Vizou-built theme, not a file this plugin ships or requires — a from-scratch theme just needs an element carrying both .lang-switch and .current-lang, wherever it makes sense to put one.)

7. Checklist for new theme templates

  1. Does the template show secondary language content at all? If not, nothing here applies.
  2. Does it check vizou_bling_current_language() (or the ACF field directly, per section 2) rather than reading $_COOKIE directly?
  3. If it branches server-side on language, is that acceptable for this template even under page caching (section 3) — or should it render both languages and let JS toggle instead (section 4)?
  4. Are visitor-facing strings pulled from vizou_bling_string() rather than hardcoded, so Settings and per-template filters both work?
  5. If this template includes a language switcher, does it carry both .lang-switch and .current-lang (section 6)?