Numable authoring docsthe same content is available in the terminal: `numable docs <topic>`中文

i18n — localization (content table (A) / metadata table (B))

Audience: people building a tool (an XBundle package), and the AI working on their behalf. Both read the same document.

What it is

A package holds two kinds of translatable string, and they are written completely differently. Mixing them up raises no error — it just silently does nothing:

The judgement in one line:

Any field the host reads and displays directly belongs in the metadata table (B); anything the package's own content resolves through the evaluator belongs in the content table (A).

A package runs fine without localization — but publishing is blocked: the storefront copy (name / summary) must exist in both Chinese and English.

Content table (A) vs metadata table (B)

Content table (A) Metadata table (B)
Where it lives The file's top-level i18n (in .rcn it is rc.i18n) Hangs off the object as i18n: {locale:{field}}; the bare field is the base language
Shape { locale: { key: text } }, two flat levels { locale: { fieldName: text } }
Reference The body writes ${@i18n.key} No reference; the host picks for itself
Carriers .rcn .xpage .xform .xmenu .af .df manifest.json, .xwidget, routes[] in router.json, .xjob
What an empty string means A valid translation — deliberately empty; it renders empty and does not fall back Empty = missing, so the chain keeps falling back
Fallback granularity Per key (one missing line in one locale fills in just that line) Per field
check G8 G19 (manifest, E) · G20 (.xwidget, W) · G8's own section (router, W) · G47 (.xjob)

The two tables treat the empty string in opposite ways, and that is deliberate. In the content table it is perfectly normal for zh-CN to say "天" while en-US says "" (English does not want that unit suffix); treating it as missing would render "Day 3天". An empty title in the metadata table, on the other hand, has never meant anything but an omission.

.xjob (reminders and background jobs) is the exception inside the metadata table: its message has this run's data substituted in, so a translation may write ${} directly, and its set of references must match the base language exactly (G47); the form slot can also override input hint text and option text. The full recipe is step 5 of numable docs alerts.

Language resolution: the fallback chain

Ask for a locale and it is tried first; then English; only then the author's own locale:

locale → other locales in the same language subtag (sorted by name) → en-US → manifest.lang (default zh-CN) → zh-CN → the remaining locales in the table

manifest.lang is the base language, defaulting to zh-CN. It decides which locale every bare field belongs to, so getting it wrong tilts the fallback direction for the entire package.

The two language values

Value What it is Who sees it
Shell language The app's own interface language, only Chinese or English Platform buttons, menus, system copy
Content locale The language fed to package content; any BCP-47 tag (ja-JP, de-DE, …) @app.language / @app.locale, content/metadata table resolution, appInfo().language in H5

When the user explicitly picks Chinese or English the two are the same. They only diverge under "follow the system" when the system is neither Chinese nor English: the shell falls back to English while the content stays ja-JP — a package with a Japanese locale renders Japanese, one without it follows the chain above to English. So a package may support any locale, unconstrained by the shell's two.

Inside package content, always read the language as ${@app.language}.

Data flows: fetch by language, and produce text too

A .df may read ${@app.language} directly (as well as @device.language / @time.locale). For data the API itself serves per language (news headlines, city names, store descriptions), splice the language straight into the request:

{
  "id": "news",
  "action": "request",
  "params": {
    "url": "https://api.example.com/news?lang=${@app.language}",
    "formatType": "json"
  }
}

When the user switches language, the dashboard runs one widget.refresh per package (clear the fetch timer + a real fetch), so language-dependent data is updated. Switching language is rare, so refetching once is acceptable. Action flows (.af) may read the language too.

A .df has exactly the same shape as an .af: it may carry a top-level i18n table, the body writes ${@i18n.key}, and the flow's input carries the host page's table ⊕ this file's table (plus @app). To produce finished text in the data layer ("About the same as yesterday", "59% into today's range"), do it right in the .df:

{
  "version": 1,
  "actions": [
    { "id": "resp", "action": "request", "params": { "url": "https://api.example.com/today?lang=${@app.language}", "formatType": "json" } },
    { "op": "set", "props": { "key": "_b", "value": "1" } },
    { "op": "set", "props": { "key": "delta", "value": "${resp.delta}" } },
    { "op": "set", "props": { "key": "summary", "value": "$[if::(gt::(${delta},0),${@i18n.up},${@i18n.flat})]" } },
    { "action": "resultFilter", "params": { "keys": ["delta", "summary"] } }
  ],
  "i18n": {
    "zh-CN": { "up": "比昨天高", "flat": "和昨天差不多" },
    "en-US": { "up": "Higher than yesterday", "flat": "About the same as yesterday" }
  }
}

In one sentence: any flow file may have a top-level i18n table; write text as ${@i18n.key} and branch on language with ${@app.language}. Whether a plain word like "today" lives in the data layer or the rendering layer is your call. check applies the same G8 checks to a .df table as to an .af.

Two things to know:

Which layer a content table (A) applies to (scope merging)

Situation Which content table is visible
${@i18n.key} inside a .rcn The page's table ⊕ that .rcn's rc.i18n (on a key collision the .rcn's own wins)
${@i18n.key} on an .xpage node (params / props.text / menu[].label) The page's table (the i18n at the top level of the .xpage); keys from a .rcn table are not visible here
An .af event flow The host's table ⊕ that .af's i18n; an event flow on a .xwidget has no host table, only its own
A .df data flow The host page's table ⊕ that .df's i18n; a data flow on a .xwidget has no host table, only its own
.xform Only its own table

The corollary: text drawn on widgets and pages is safest living in each .rcn's rc.i18n; finished sentences assembled in the data layer live in the .df's own table.

Content table (A) specifics

A minimal bilingual example, block by block

manifest.json — metadata table (B)

{
  "lang": "zh-CN",                       // base language: every bare field below belongs to this locale
  "title": "GitHub 示例",
  "subtitle": "某个用户最近的公开动态",
  "category": "developer",               // an enum key; the platform ships its own wording, no translation needed
  "i18n": {
    "en-US": { "title": "GitHub Sample", "subtitle": "A user's recent public activity" }
  }
}

Only four fields are translatable: title / subtitle / category / description. Since category is an enum key, it needs no translation.

.xwidget — metadata table (B)

{
  "version": 2,
  "title": "个股",
  "sub": "价格 · 两个月形状",
  "i18n": { "en-US": { "title": "Stock", "sub": "Price and its 2-month shape" } },
  "layout": 22
}

Only two fields are recognized: title and sub.

router.json — metadata table (B), hanging off each route

{
  "routes": [
    { "path": "/", "entry": "html/home/index.html" },
    { "path": "/detail", "entry": "html/detail/index.html",
      "title": "个股详情",
      "i18n": { "en-US": { "title": "Stock detail" } } }
  ]
}

Do not put a word table at the top level of router.json, and do not write ${@i18n.key} inside title — route titles are not evaluated, so the title bar shows the raw template string, and check always reports it.

.rcn — content table (A)

{
  "rc": {
    "cells": [
      { "id": "t", "type": "txt", "text": "${@i18n.title}", "fontSize": "18pt",
        "textColor": "#1A1A1A|#FFFFFF", "x": "12pt", "y": "16pt", "w": "-1", "h": "-1" },
      { "id": "u", "type": "txt", "text": "${temp}${@i18n.deg_post}", "fontSize": "13pt",
        "textColor": "#6B7280|#9AA0A6", "x": "12pt", "y": "44pt", "w": "-1", "h": "-1" }
    ],
    "i18n": {
      "zh-CN": { "title": "全球速览", "deg_post": "度" },
      "en-US": { "title": "World Markets", "deg_post": "" }
    },
    "i18nEmptyOk": ["deg_post"]
  }
}

deg_post is deliberately empty in English (English does not carry that suffix), and i18nEmptyOk states so, keeping it from being read as an omission.

.xpage — content table (A) at the top level, text living in each .rcn

{
  "type": "page",
  "id": "demo",
  "i18n": { "zh-CN": { "empty": "还没有数据" }, "en-US": { "empty": "Nothing yet" } },
  "root": { "…": "…" }
}

The page's table merges with the tables of the .rcn / .af files it references. Do not write ${@i18n.key} in a node's own properties — see the rules table.

.af — content table (A) at the top level

{
  "version": 1,
  "i18n": { "zh-CN": { "done": "已保存" }, "en-US": { "done": "Saved" } },
  "actions": [
    { "action": "ui.toast", "params": { "message": "${@i18n.done}", "type": "success" } }
  ]
}

A .df data flow's table has the same shape; see the example under "Data flows: fetch by language, and produce text too" above.

H5 pages — they carry their own table

The bridge offers no translation facility; an html page keeps its own word table and reads the language from data-lang:

<script>
  const T = {
    "zh-CN": { title: "我的记录", empty: "还没有记录" },
    "en-US": { title: "My log",  empty: "Nothing yet" }
  };
  const pick = () => T[document.documentElement.dataset.lang] || T["en-US"];
  function render() {
    document.getElementById("h").textContent = pick().title;
  }
  addEventListener("languagechange", render);   // switching the language dispatches an event, it does not reload the page
  render();
</script>

You can also read info.data.language via const info = await xbridge.appInfo(); (it must be awaited; the bridge returns an envelope with the result in data). See numable docs bridge.

Rules (breaking one means rework)

Rule How it is checked Symptom when broken Fix
A content table must be a flat object with no values wrapper check G8 The reference does not resolve and the literal ${@i18n.xxx} shows on screen Rewrite it as {locale:{key:value}}
Keys referenced in the body must exist in the required locales (zh-CN / en-US / manifest.lang) check G8 The raw template string shows on screen in that locale Add the key; if it is deliberately empty, declare it in i18nEmptyOk
manifest.lang must be a valid BCP-47 tag check G8 The fallback chain tilts and bare fields are assigned to the wrong locale Write a standard tag such as zh-CN / en-US
router.json must have no top-level i18n word table, and routes[].title must not reference ${@i18n.key} check G8 The title bar shows the raw template string Move it to a per-route routes[].i18n
Locales should carry the same key set; keys in the table that the body never references are dead weight check G8 (W) One language is missing a line; or the translation table is full of keys deleted long ago Fill in / delete
routes[].title needs coverage in a non-base locale check G8 (W) The title bar shows Chinese in an English environment Add routes[].i18n["en-US"].title
Do not hard-code Chinese in user-visible fields check G8b (W) English-speaking users see Chinese Extract it into a ${@i18n.key}
manifest.i18n["en-US"].title and .subtitle are required (except when the base language is itself en-*) check G19 The store and the install panel show Chinese in English Add them
Locales in manifest.i18n must be BCP-47 and each must be an object; the only translatable fields are title / subtitle / category / description check G21 A field outside the allowlist is never read Stick to the allowlist
The package name's display width is ≤ 24 in every locale (full-width counts 2, half-width 1) check G21 It does not fit two lines in the widget grid and the title is truncated Shorten title (≤ 16 recommended)
.xwidget.i18n must be an object and must cover title / sub for en-US check G20 (W) The widget title shows Chinese in an English environment Add the metadata table
Any ${@i18n.key} used at XPage node level must exist in the i18n table at the top level of the .xpage check G35 With the key missing, that spot renders blank Add the key to the page's table; text drawn inside a Canvas still lives in the .rcn's rc.i18n
An empty string means "deliberately empty" in the content table and "missing" in the metadata table manual review Written backwards: leaving untranslated content-table entries blank → that line really is blank in English; expecting a blank metadata field to fall back → it falls through to another locale Finish the content-table translations, and declare intentional blanks in i18nEmptyOk; for a missing metadata translation, delete the key rather than writing ""

When something goes wrong

Symptom Most likely cause What to do first
The literal ${@i18n.xxx} appears on screen, or the spot is blank The key is not in the table that carrier reads (a .rcn reads the rc table, an XPage node reads the page table) / the table has the wrong shape Run numable check and look at G8 / G35; add the key to the right table
The title bar shows a template string routes[].title was written as ${@i18n.} Move it to a per-route routes[].i18n
The widget title is Chinese in English The .xwidget has no metadata table Add i18n["en-US"].title/sub, see G20
One line is blank in English That locale has "" in the content table without meaning to Add the translation; if it is intentional, declare it in i18nEmptyOk
After switching language the widget shows a skeleton first and the data arrives a moment later; offline it shows the empty state The data cache is split by language: there is no stored data for the new language yet, it shows once fetched; offline there is no data for that language to show Expected behavior; pull to refresh once online
Widget text does not change after switching language The text is hard-coded in the .df or .rcn instead of going through ${@i18n.key}; or the data does not vary by language at all Add a top-level i18n table and switch the text to ${@i18n.key}; to fetch by language read ${@app.language}
A Japanese system shows Chinese The package has only the zh-CN locale and manifest.lang is zh-CN Add an en-US locale (English comes before the base language in the chain)

Related