shop-i18n
Make a storefront multi-language on this multi-tenant content platform via the shop MCP — UI-string localization with the Liquid `| t` filter + per-store `locales/<lang>.json` theme files, the customizer-editable-AND-localized "Pattern B", per-store locale, and the bilingual one-store-per-language model for dynamic content (products/pages/blog). Use when the user wants a store in EN+VI (or any language pair), asks why text shows English on a VI store, or wants localized section copy. Read `shop-core-rules` first; deep repo/durability mechanics live in `storefront-theme-design` (Pattern B).
Read
shop-core-rulesfirst (§A confirm-plan, §B design-spec, §D verify, §F design contract, §G palette). The deep repo/durability mechanics of i18n (the seeded locale files ontheme_id=1,buildTranslations, the dual-seeder rule) live instorefront-theme-design("Pattern B", §72) — this skill is the MCP-live how-to. Don't duplicate; cross-reference.
What the platform DOES and DOESN'T support (verified)
- ✅ UI-string i18n via the Liquid
{{ 'some.key' | t }}filter, backed bylocales/<lang>.jsontheme files. (frontend/modules/storefront/lib/liquid/filters/index.ts+theme-loader.buildTranslations.) - ✅ Per-store DEFAULT locale:
store.locale→x-locale→shop.locale/request.locale.iso_code. Served at the un-prefixed URL. - ✅ TRUE one-store-multi-language (translation OVERLAY — Shopify "Translate & Adapt"). NEW; supersedes the old one-store-per-language workaround. Author once in the default locale; per-locale OVERRIDES live in the
content_translationstable and apply at render for the active locale; a missing override falls back to the default value.- Runtime switching via
/vi/path prefix — the edge proxy strips a supported-locale prefix and serves that locale's overlay (no?lang=/cookie). Default locale = no prefix. - Per-locale DYNAMIC content — products/collections/pages/blog + customizer-edited theme section text ARE translatable within ONE store now.
- hreflang alternates auto-emitted;
shop.supported_locales,shop.locale_default, andlocalization.available_languages[{iso_code,url,active}]exposed so a theme renders a language switcher.
- Runtime switching via
- ⚠️ Status: built but may be UNCOMMITTED/undeployed — confirm the
content_translationsmigration is applied + the build is live before relying on it. The one-store-per-language bilingual model still works as a fallback.
Make a store multi-language (via the shop MCP)
Curated MCP tools (preferred over raw GraphQL):
- Enable languages —
set_languages({ locales:["en","vi"] })(MUST include the store default; short codes become the URL prefix →vi=/vi/). Check current withget_languages. (Admin UI: Settings ▸ Languages.) - Translate —
upsert_translations({ entries:[{resource_type, resource_id, field, locale, value}] }).resource_type∈theme_section|page_section|theme_setting|product|collection|page|blog_post|content_record|menu_itemplus the global/commerce typeseav_attribute|eav_option|brand|store|personalization_template|personalization_field|personalization_choice|sales_rule|customer_tier;field= the storefront object field name (product/collectiontitle/description/meta_*, pagetitle/body, blogtitle/excerpt/content, menu_itemtitle, a content_record field code, or a theme section setting key); emptyvaluedeletes the override. Read back withlist_translations({ locale, resource_types }).- To get resource ids:
list_products/list_collections/list_content_records/etc. returnid; forpage_sectionuse"<pageId>:<instanceId>". - Block text inside a section (FAQ Q&A, cards, slides…): blocks are an ORDERED ARRAY with no id, so target them with
field = "block:<index>:<settingKey>"(0-based), e.g.block:0:question/block:0:answerfor the 1st FAQ item onresource_type:"theme_section", resource_id:"faq". Section-level settings use the bare key. - Catalog / EAV / brand / store / personalization (translate the parts that aren't free prose):
eav_attribute— the spec-table / filter-sidebar / variant-selector HEADER.resource_id= theattribute_code(e.g.color,size),field=label. GLOBAL — translate "Color"→"Màu" once and it applies to every product. Get codes vialist_attributes.eav_option— an attribute's option text (the swatch/filter/spec VALUE).resource_id= theoption_id(integer),field=value, e.g. "Red"→"Rouge". GLOBAL.list_attributesreturns each attribute'soptions[{ option_id, value }].product(free-text spec values) — for a per-product spec value that isn't an option, writeresource_type:"product", resource_id:<product id>, field:<attribute_code>.brand—resource_id= the brand NAME string (brands have no storefront id; the facet/product.brand_namecarry the name),field=name. Caveat: renaming a brand orphans the override.store— the storefront's own name.resource_id= the store id (fromget_context),field=name.personalization_template|_field|_choice— PDP personalization form.resource_id=<template_id>/<template_id>:<field_code>/<template_id>:<field_code>:<choice_value>; fieldsname/label+help_text/label. (Personalization labels are also bilingual {en,vi} in the personalization editor; this overlay supplements arbitrary locales.)sales_rule— cart/checkout discount line.resource_id= the rule's display description string (no id on the cart line),field=description.customer_tier— loyalty tier name.resource_id= the tier id,field=name. (Checkout shipping-method titles render in a client island and aren't overlaid yet.)
- Swatches: translating an
eav_optionvalue ALSO localizes a text swatch pill on the storefront (visual color/image swatches keep their hex/url). Oneeav_optiontranslation covers the facet sidebar, the variant selector, and the swatch. - Admin UI (if a human prefers clicking): every entity translates inline in ITS OWN editor (no central page) — product editor (incl. per-product spec values), collection/page/blog editors, menu drawer, theme customizer, the attribute edit page (label + option values), and store name under Settings ▸ General. EAV attribute labels + option values are GLOBAL there too.
- To get resource ids:
- Switcher/SEO — themes render the switcher from
localization.available_languages(Liquid);hreflangalternates are emitted automatically.
Raw-GraphQL equivalents (if needed): updateStore(input:{supported_locales}), upsertTranslations, storeTranslations, public publicTranslations(store_slug, locale). Admin UI also has inline per-language editing in the customizer / page-builder / menu drawer + side-by-side cards in the page/blog/product/collection/content-record editors.
| t UI-string localization (below) is STILL the tool for fixed theme chrome labels; the overlay is for customizer-edited + dynamic content.
ALWAYS add a language switcher (multi-language stores)
A multi-language store NEEDS a visible switcher (header or footer) so customers can change language — the storefront does NOT add one automatically. Render it from the localization Liquid drop (exposed by the renderer; no setup needed):
localization.available_languages→[{ iso_code, url, active }]—urlis the CURRENT page under that language (default = root, others/<locale>/…).localization.language(active code) ·localization.default_locale· alsoshop.supported_locales.
Drop this into the header/footer section (or a snippet) — it auto-hides for single-language stores:
{% if localization.available_languages.size > 1 %}
<nav class="lang-switch" aria-label="Language">
{% assign labels = 'en:English,fr:Français,es:Español,ja:日本語' | split: ',' %}
{% for lang in localization.available_languages %}
<a href="{{ lang.url }}" hreflang="{{ lang.iso_code }}"
class="lang-switch__item{% if lang.active %} is-active{% endif %}"
{% if lang.active %}aria-current="true"{% endif %}>
{{ lang.iso_code }}
</a>
{% endfor %}
</nav>
{% endif %}
(Map iso_code→pretty label as needed; the drop only carries the code.) When building/redesigning an i18n store via the MCP, ADD this to the header section with build_section/upsert_theme_file — a store with supported_locales but no switcher is incomplete.
Nav links stay in-locale automatically — the renderer rewrites internal href/action="/…" to /<locale>/… on a non-default-locale page, so clicking a product/collection/menu link KEEPS the language (no cookie; URLs stay canonical + shareable). Theme authors do NOT need locale-aware link filters; just use {{ product.url }} etc. as normal.
Rule 1 — Pattern B: localized AND customizer-editable (NEVER | t alone)
In every section/layout body, a piece of text that should be both editable AND localized uses:
{{ section.settings.title | default: 'cart.heading' | t }}
- Merchant set a value in the customizer → it wins (LiquidJS treats
""as falsy). - Unset → falls through to the i18n KEY
'cart.heading'→| tlocalizes per the store's locale. - Anti-patterns (both wrong):
{{ 'cart.heading' | t }}alone (customizer field does nothing — the panel lies to the merchant); a hardcodedAdd to cartliteral in markup (never localizes, NO-HARDCODE per core §H). - Build sections with
shop-build-section; this| default: 'key' | tform is the i18n half of every text setting.
Rule 2 — locale files (ship + extend via MCP)
- Files are theme files:
locales/en.jsonandlocales/vi_VN.json(addlocales/vi.jsontoo — the lang fallback). Nested JSON, dot-flattened at load → keyheader.searchcomes from{"header":{"search":"..."}}. - Write/extend them with
upsert_theme_file:path="locales/vi_VN.json",content_type="json",content="{...}". - EVERY key MUST exist in BOTH
enandvi_VN(and any other supported lang). A missing key →| trenders the raw key string (cart.headingshows literally). Keep keys grouped (header.*,cart.*,product.*,footer.*,checkout.*). - For a content/web store, locale files come from
web-base(it ownslocales/*copied from theme 1 — seestorefront-theme-design); to add custom keys, upsert them onto the STORE's own theme so they override/extend.
Rule 3 — | t filter limits (don't fight them)
- Lookup is a flat dot-key map; no nested traversal at call time (flattening happens at load).
- The only option is
| t: default: '...'— NO interpolation (| t: name: 'Jane'is NOT supported). For "Hello, {name}" build it with Liquid:{{ 'greeting.hello' | t }}{{ customer.name }}or{% assign g = 'x' | t %}{{ g | replace: '%name%', customer.name }}. - Missing key/locale →
default→ raw key (never errors).
Rule 4 — the bilingual model for CONTENT (one store per language)
Different languages of the actual catalog/pages = separate stores, each with its own locale:
- EN store
acme(locale=en) + VI storeacme-vn(locale=vi_VN). Slugs are GLOBALLY unique. - Set
store.localeat creation (createStore/createShop) or viaupdateStore(graphql_mutation escape hatch if no high-level field). The locale drives whichlocales/*.json| tuses. - Products are org-scoped (
UNIQUE(organization_id, sku)) — if both stores share an org, give each language a distinct SKU prefix (e.g.EN-…/VN-…); otherwise put each language in its own org. - Cross-link the two stores manually (a menu item / footer link to the other language's domain) — there is no automatic switcher.
Set the theme's locale metadata
locale_default(theme column, defaulten) feeds thebuildTranslationsfallback — set it to the store's primary language so missing keys fall back sensibly.supported_localesexists but is NOT consumed by any switcher yet — setting it is harmless metadata, not a feature toggle. Don't promise a picker from it.
Verify (core §D)
- Render the store on its real domain/locale; confirm
| tkeys RESOLVE (no rawcart.headingliterals leaking). Toggle a customizer text field → it overrides the localized default (Pattern B working). - For a bilingual pair, open BOTH stores and confirm each shows its language end-to-end (header/cart/product/footer + catalog).
- "English on a VI store" symptom → a key missing from
vi_VN.json, or a hardcoded literal, orstore.localenot set tovi_VN. (Debugging deep-dive:storefront-theme-design.)
If the user wants TRUE one-store-multi-language (switcher / /vi/ / hreflang)
That is NOT supported today and is a Layer-2 build (frontend render-theme.ts + context-builder.ts + proxy locale routing + a switcher UI + per-locale content storage). Say so honestly and scope it as a feature, not an MCP-live task. Don't fake a switcher with the current primitives.
Related: shop-core-rules (§F design contract, §H NO-HARDCODE), storefront-theme-design (Pattern B + locale-file durability/seeders), shop-build-section (where the | default:'key'|t text settings live), shop-content-site (content stores + web-base locale files).
