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:
- Content table (A) — the text you write yourself, on widgets and pages, including finished sentences assembled in a data flow. It lives in one
i18ntable at the top level of the file and is referenced from the body with${@i18n.key}. - Metadata table (B) — the text the platform reads and displays itself: package name, package summary, widget title, route title. The bare field is the base language, translations hang off the same object under
i18n, and nothing in the body references them.
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
- Only locales that actually exist in the table are considered; spellings such as
zh-Hans-CNandzh-CNare normalized before matching. - The content table layers this chain per key: one missing line in one locale fills in just that line, and the table as a whole never drops to another language.
- The metadata table treats the bare field as the
manifest.langlocale, then walks the first four segments of the same chain (exact → other locales in the same subtag → en-US → base language, without falling through to the remaining locales) and takes the first non-empty value.
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:
- The data-cache key includes the language. After a switch, no widget has stored data for the new language yet: a skeleton shows first, then the data once fetched; switching language offline leaves no data to show for that language (the empty or error state appears). That is a deliberate trade-off.
- The theme (light/dark) is different from the language: it is a pure render parameter, switching theme only re-renders and never fetches, and the data cache is not split by theme either; colors rely on
light|darkpairs.
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
- Two flat levels:
{ "zh-CN": { "title": "…" }, "en-US": { "title": "…" } }. Do not wrap it in avalueslayer (checkrejects it). - Keys use
[A-Za-z0-9_]only, and values must be strings. - Keys cannot be built dynamically:
${@i18n.k_${n}}resolves to nothing. An unresolved reference is kept as its literal string in RCN / XPage, so${@i18n.xxx}appears on screen. - Interpolate by splitting into several keys rather than putting a placeholder in the translation and substituting it yourself:
"${@i18n.pre}${n}${@i18n.post}". - Plurals go in
key_one/key_other, picked in the body with$[if::(…)]. - A locale may be any valid BCP-47 tag;
zh-CNanden-USare required locales (along with themanifest.langone), while a missing key in any other locale only raises a notice. - A deliberately empty translation gets a declaration alongside it so it is not read as an omission: in
.rcnwriterc.i18nEmptyOk, in every other file a top-leveli18nEmptyOk, holding an array of keys.
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
numable docs layout— every field ofmanifest.json, including the base languagelangnumable docs page—router.json's fields and the three page kindsnumable docs rcn— how to write${@i18n.key}and light|dark color pairs in a.rcn