Skip to main content
A block declares the strings it owns, ships catalogs for the locales it supports, and reads translations through TranslationContext. The portal switches locales, the theme adds overrides, and a single merchant can rewrite one string on one block instance — all without the block author writing any glue code.

Where translations live

There are four layers of resolution, applied in order until one wins:
  1. Block instance override — the merchant rewrote this string on this specific block in the editor.
  2. Block default in override mode — the block ships its own default.
  3. Theme override — the theme rewrote this key across all blocks.
  4. Theme default — the theme’s own translation catalog.
If no layer has a value, the resolver returns the key itself.

Declaring locales on a block

A block opts in by adding a locales field to defineBlock — a list of supported codes and an async loader:
import { defineBlock } from "@juo/blocks";

export const CheckoutBlock = defineBlock("Checkout", {
  group: "theme",
  schema: { /* ... */ } as const,
  initialValue: () => ({ props: {} }),
  locales: {
    supported: ["en", "de", "nl"],
    async load(locale) {
      // Each file exports a flat object of { key: "translated string" }.
      return (await import(`./locales/${locale}.json`)).default;
    },
  },
  renderer(block) { /* ... */ },
});
A locale file is plain JSON:
// locales/de.json
{
  "checkout.title": "Zur Kasse",
  "checkout.cta": "Bestellen",
  "overrides": {
    "checkout.cta": "Jetzt bestellen"
  }
}
Top-level keys are the block defaults. The optional overrides object lets a block ship preferred wording for keys it defines — used by the resolver when the theme has no opinion.

Reading translations

Resolve a translation through TranslationContext. From a vanilla renderer:
import { defineBlock, effect, untracked, injectContext } from "@juo/blocks";
import { TranslationContext } from "@juo/blocks";

defineBlock("Checkout", {
  // ...
  renderer(block) {
    const el = document.createElement("button");
    const t = injectContext(el, TranslationContext);

    effect(() => {
      // Reading t.locale.value re-runs this effect on locale change
      const _locale = t.locale.value;
      const label = t.resolve(block.type, block.id, "checkout.cta");
      untracked(() => {
        el.textContent = label;
      });
    });

    return el;
  },
});
resolve(blockType, blockId, key) takes the block’s type and instance id so it can apply the per-instance overrides set by the merchant.

From a framework

Vue, React, and Preact all reach TranslationContext through their useContext — see the Vue, React, and Preact guides.

Using <juo-text> for inline text

For inline translated strings rendered directly in HTML, <juo-text> is a declarative alternative to wiring up TranslationContext by hand. It is a custom element that handles resolution and reactivity internally:
<juo-text prop="checkout.cta">Place order</juo-text>
The element reads the prop attribute as the translation key, walks up the DOM to find the nearest block wrapper (data-block-type / data-block-id), injects TranslationContext, and re-renders whenever the locale changes. The text content is used as a fallback: if the key resolves to itself (nothing matched), the element shows the fallback instead. From a vanilla renderer:
renderer(block) {
  const btn = document.createElement("button");
  btn.innerHTML = `<juo-text prop="checkout.cta">Place order</juo-text>`;
  return btn;
},
From Preact or React (using h directly):
h("juo-text", { prop: "checkout.cta" }, props.cta)

hide-parent-if-empty

Add the hide-parent-if-empty attribute to automatically set hidden on the element’s parent when the resolved text is empty:
<p><juo-text prop="checkout.subtitle" hide-parent-if-empty>Free shipping on all orders</juo-text></p>
The <p> is hidden entirely when the merchant has cleared the subtitle — no empty whitespace or layout gap remains.

Resolution order in detail

The translation service walks the catalog from most-specific to most-general:
StepSourceWins when
1catalogs.blocks.overrides[blockId][key]The merchant overrode this string on this block instance.
2catalogs.blocks.defaults[blockType][key] (mode "override")The block ships a default and prefers it over the theme.
3catalogs.theme.overrides[key]The theme rewrote this key for the whole portal.
4catalogs.theme.defaults[key] (or loadDefaultTranslations)The theme provides a translation.
5catalogs.blocks.defaults[blockType][key] (mode "default")The block has a fallback default.
6The key itselfNothing matched — a developer signal that something’s missing.
A block can flag a key as "default" rather than "override" to let the theme’s wording win when both exist — useful for generic strings like "add_to_cart" that the theme is likely to style consistently.

Setting up TranslationContext

The portal creates one translation service per <juo-context-root> and feeds it the theme’s locale state:
import {
  provideContext,
  createTranslationService,
  TranslationContext,
  ThemeStateContext,
  createThemeState,
} from "@juo/blocks";

const themeState = createThemeState({ /* ... */ });
provideContext(root, ThemeStateContext, themeState);

provideContext(
  root,
  TranslationContext,
  createTranslationService(themeState.locales, {
    async loadDefaultTranslations(locale) {
      // Theme-wide catalog, e.g. shipped with the theme package.
      return (await import(`./theme-locales/${locale}.json`)).default;
    },
  }),
);
themeState.locales exposes locale (a Signal<string>), catalogs (the merged block + theme catalogs for the active locale), and a setLocale setter. Changing the locale reloads catalogs and re-runs every effect() that read t.locale.value.

Switching locales

The portal exposes a locale switcher by reading and writing themeState.locales:
import { injectContext, ThemeStateContext } from "@juo/blocks";

const theme = injectContext(el, ThemeStateContext);

theme.locales.setLocale("de");        // triggers catalog reload
console.log(theme.locales.locale.value); // "de"
Every block that read a translation through effect() updates automatically.

Overriding theme strings from a portal

When a portal wants to ship its own translations for keys other blocks own, pass them through loadDefaultTranslations — the result feeds the theme.defaults layer used by step 4 of the resolver. For per-key forced overrides (step 3), populate catalogs.theme.overrides from the theme state.

Tips

  • One key, one block. Namespace keys by block (checkout.cta, cart.empty). Resolution is global, but lint-style discipline keeps catalogs readable.
  • Ship en always. It’s the safe fallback when a locale file fails to load.
  • Test against untracked(). To get a translated string inside a render that already depends on block.props, wrap the resolve() call so the DOM write doesn’t tangle dependencies with the props read.