Hashi

Screenshot of the hashi demo homepage

A fictional cross-border design consultancy with offices in Tokyo, Mexico City, and Berlin, surfaced in English, Spanish, and Japanese with locale-symmetric URLs, hreflang on every translated page, CJK + Latin typography harmonized through the Noto family, and a new shared language-switcher block that lists the current page’s translations rather than generic language links. Live at hashi.wp.philiprehberger.com.

The challenge

Clients with international audiences often hear — from agencies, from technical leads, from past experience — that “WordPress can’t really do multilingual without WPML’s $200+/yr license, and even then the URLs end up ugly and the SEO is half-broken.” Hashi argues against that. The brief: a demo that proves WordPress can deliver credible multilingual at zero plugin cost — with locale-symmetric URLs (/en/practice/, /es/servicios/, /ja/サービス/), one set of hreflang tags per translated URL, an <html lang="..."> attribute that reflects the actual page language, and a header switcher that lists this page’s translations, not a generic flag bar that strands a Japanese reader on an English page.

The approach

Polylang free is the engine. Three languages registered: en, es, ja. URL strategy is subdirectory with force_lang=1, hide_default=0 so /en/ is always prefixed (symmetry beats asymmetric /practice/ vs /es/servicios/). Locale-appropriate slugs — servicios not practice, サービス not practice — because the URL is part of the brand expression. A new shared block, sfp-blocks/language-switcher, server-renders the current queried object’s translations via pll_get_post(); languages with no translation are rendered aria-disabled with a title tooltip rather than as a phantom link that 404s. The Noto family handles the typography problem: Noto Serif Display + Inter for Latin, Noto Serif JP + Noto Sans JP for CJK. Latin-only visitors never download the CJK fonts — functions.php reads pll_current_language() and conditionally enqueues a style-cjk.css + the CJK font subset only when the current language is ja.

What’s on the site

  • Five main pages × three languages = 15 page rows. Home, Studio, Practice (Servicios / サービス), Work (Trabajo / 事例), Contact (Contacto / お問い合わせ). Every URL returns 200; every translated URL emits <link rel="alternate" hreflang="..."> for the other two languages plus x-default.
  • Three case studies × three languages = 9 rows. A Mexico City coffee chain rebrand expanding into Tokyo, a Tokyo Modern Art Museum bilingual wayfinding system, and a Berlin bicycle manufacturer brand expansion. Each is a 200-word reconstruction of a real engagement shape — brief, approach, deliverables, sidebar with client / year / locations / roles. Nested under /work/, so the URL is /en/work/rebranding-mexico-city-coffee-chain//es/trabajo/rebranding-cadena-cafe-ciudad-de-mexico//ja/事例/メキシコシティ-カフェチェーン-リブランディング/. The Japanese slug is genuine Unicode — the address bar shows it correctly, not as URL-encoded percent-escapes.
  • Language switcher in the header. Compact “EN · ES · JA” form, current language underlined in vermilion. Click any non-current language and you land at the translated URL of this page, not the language home.
  • Language switcher in the footer. Full native-name form (“English · Español · 日本語”). Same block, different displayStyle attribute.
  • Locale-aware demo banner. The platform’s sfp-demo-banner mu-plugin is filtered per-locale — Japanese visitors see Japanese banner copy (“Hashi はフィクションです。”), Spanish visitors see Spanish (“Hashi es ficticio.”), no machine-translated mush.

The stack in detail

  • Per-demo theme hashi, child of _chassis. Indigo + paper + vermilion + sumi ink + ash palette in theme.json; four font-family slugs (sans Inter / serif Noto Serif Display / sans-jp Noto Sans JP / serif-jp Noto Serif JP). Templates for front-page, page, single-case-study, 404. Header part embeds the language-switcher block; footer part too.
  • New shared block sfp-blocks/language-switcher. Server-rendered via PHP. Four attributes: displayStyle (name vs code), separator, showCurrentAs (underline / bold / dim), fallbackBehavior (disabled vs language-home for untranslated pages). Reads pll_the_languages() + pll_get_post(). Available to every future i18n demo on the platform.
  • Two mu-plugins. hashi-banner-config.php — locale-aware demo banner copy filtered through sfp_demo_banner_config. hashi-polylang-setup.php — exposes the translation linker helper used by the seed.
  • Bootstrap script. .scripts/bootstrap-polylang.php runs once via wp eval-file after Polylang activation: loads PLL_Admin_Model directly (the public API doesn’t expose add_language() in front-end / CLI context), registers the three languages, writes the polylang option with the URL strategy this demo expects.
  • Seed. .scripts/seed-pages.sh, ~1700 lines. 24 page upserts, each tagged with its Polylang language via pll_set_post_language(), then linked into 8 translation groups via pll_save_post_translations(). 19 real photos resolve via resolve_image() with ImageMagick placeholder fallback. EN home set as the WordPress static front page; Polylang routes /es/ and /ja/ to the translations automatically.
  • Conditional CJK enqueue. functions.php hooks wp_enqueue_scripts at priority 20; reads pll_current_language(); enqueues Latin fonts always, CJK fonts + style-cjk.css only on ja pages. Verified via DevTools: /en/ downloads zero CJK font bytes, /ja/ downloads ~280kb of them.

Where we made tradeoffs

  • Polylang free vs WPML. WPML is the obvious enterprise pick, but it’s $200+/yr and bundles features (string translation, WooCommerce multilingual, translation memory) this demo doesn’t need. Polylang free covers the routing, hreflang, and translation linking. WPML wins on shop-multilingual; Polylang wins on cost and on a thinner GraphQL surface if you later headless-extend it.
  • Polylang free + static front page = asymmetric URLs. The plan asked for symmetric URLs (/en/, /es/, /ja/) with hide_default=0. Polylang free doesn’t map the non-default-language URLs (/es/, /ja/) to the language’s static-front-page translation — only the default language gets that mapping (via WP core). With hide_default=0 all three localised home URLs returned home blog body class (empty blog archive) instead of the seeded home content. With hide_default=1, / serves the EN home directly, and /es/, /ja/ 301-redirect to /es/home-es/, /ja/home-ja/ respectively. One redirect hop for non-default languages, but every visitor lands on the correctly-translated home content. Inner pages (/es/servicios/, /ja/サービス/, all case studies) work cleanly across all three languages without redirects. Polylang Pro’s per-language page_on_front setting would eliminate the hop; documented in bootstrap-polylang.php.
  • Content length per locale. ~150 words per page rather than the ~500 a real client site would carry. Short, well-translated content is more credible than long, awkwardly-translated content, and the translation pass is the load-bearing quality decision on this kind of demo — the visual system can’t paper over copy that reads like machine output.
  • Polylang options not managed from a runtime mu-plugin. First version tried to enforce the polylang option on every pageload via a plugins_loaded hook. Polylang’s options class normalizes boolean values on write, and the runtime updates fought with that normalization — hide_default kept reverting. Resolution: configure Polylang once via the bootstrap script during provisioning, never from runtime code. The mu-plugin now only exposes the translation linker helper.
  • PLL_Admin_Model path lookup. Polylang 3.8 relocated the admin model to src/admin/admin-model.php (it used to be admin/admin-model.php in earlier versions). The bootstrap script probes both paths so it survives the version skew.

Outcomes

  • Multilingual proof in production. 24 page rows live, 21 verified URLs returning 200 across EN/ES/JA, hreflang tags emitting for every translated URL, <html lang="ja"> on Japanese pages, header switcher routing visitors to the translation of the page they’re on rather than the language home. Live at hashi.wp.philiprehberger.com.
  • Lighthouse: 67 / 95 / 100 / 92 (perf / a11y / best-practices / SEO). Perf is below the platform median — the CJK font conditional load lifted it from where it would have been (~55) but the 19 large source photos (~2MB each) drag LCP. A pre-processing pass to convert to WebP at 1920px max would lift perf into the high 80s without touching design; left as a documented optimisation rather than a blocker.
  • One new shared block, available to every future demo. sfp-blocks/language-switcher joins the platform’s growing block library (now twelve blocks). Any future multilingual demo — or any existing demo that adds a second language — can drop the block into a header or footer with one block-comment.
  • i18n pattern documented. The bootstrap-polylang.php script + the conditional CJK enqueue pattern + the locale-symmetric slug strategy are now reusable templates. The next CJK demo — or the next demo with a non-Latin script — doesn’t pay this learning curve again. The runbook in .guides/add-new-demo.md gets an i18n appendix capturing the PLL_Admin_Model path probe and the redirect_lang loop.
  • Pitch line. “Hashi is the answer when a client with an international audience says ‘WordPress can’t do multilingual properly,’ and they mean it.” The demo’s value isn’t novelty — Polylang has been around for a decade — it’s the working proof that the standard stack ships with locale-symmetric URLs, real hreflang, and a credible visual story across scripts.

Tagged

Industry:
Builder:
Theme:
Features: , ,

Facts

Perf

A11y

SEO

BP

Build time: hours