Skip to content

feat(#332): add translation support#744

Open
latin-panda wants to merge 26 commits intomainfrom
add-primevue-locales
Open

feat(#332): add translation support#744
latin-panda wants to merge 26 commits intomainfrom
add-primevue-locales

Conversation

@latin-panda
Copy link
Collaborator

@latin-panda latin-panda commented Mar 23, 2026

Closes #332

First exploratory iteration

I have verified this PR works in these browsers (latest versions):

  • Chrome
  • Firefox
  • Safari (macOS)
  • Safari (iOS)
  • Chrome for Android
  • Not applicable

What else has been done to verify that this works as intended?

I followed this test plan and will send the PR to QA.

Screenshot: form translated in english (Chrome) and spanish (Firefox)

Why is this the best possible solution? Were any other approaches considered?

All user-visible strings in the web-forms package are now localizable, and the active locale automatically follows the form's selected language. PrimeVue component strings (date pickers, dialogs, etc.) are also localized in sync.

How it works?

String authoring using .i18n.json files:

Strings support ICU message format, including pluralization. Translation strings are defined in .i18n.json files placed next to the component that uses them (e.g. MediaBlockBase.i18n.json is in components/common/media/). This separation maintains component readability and focus on code.

Each entry uses a component.feature.type key convention (snake_case) and includes an optional developer_comment for translators (Collect and Central uses developer_comment too):

{
  "odk_web_forms.validation.error": {
    "string": "{count, plural, one {# question with error} other {# questions with errors}}",
    "developer_comment": "Error banner message. {count} is the number of validation violations."
  }
}

Build step:

A scripts/merge-translations.js script (run as build:translations, which also runs automatically as part of build) walks src/ recursively, finds all .i18n.json files, and merges them into locales/strings_en.json. The script fails loudly on duplicate keys, preventing accidental collisions across components.

The locales/strings_en.json is the single source of truth for English strings and the file that Transifex reads.

Transifex integration:

We are doing the same as Central here.

Transifex automatically pulls strings_en.json daily as the source. Before each release, translated files are synced into locales/strings_<lang>.json by executing tx pull -a -f --mode translator. This command references the .tx/config file to filter by project, and the minimum_perc = 50 setting ensures only locales with at least 50% coverage are imported. The translator mode is used primarily to match Central's behavior, but we don't really need it. It pre-fills missing strings with texts from strings_en.json.

Runtime:

The src/lib/locale/useLocale.ts is the core runtime composable. It is instantiated once at the OdkWebForm root component and provides setLanguage and formatMessage functions. The latter is shared with other components via provide/inject, so child components inject formatMessage, and no plugin globals or wrapper components are used.

When the form's available languages change (or on mount), useLocale selects the active locale using this priority order:

  1. Saved preference in localStorage (key: odk-web-forms-locale)
  2. Browser language (navigator.languages) matched against form languages
  3. First language in the form's language list as per specs.

Both saved and browser locale matching support base-language fallback: a browser preference of fr-CA will match a form language of fr if no exact fr-CA match exists.

When no form languages are available at all (e.g. during a load error), the browser's locale is used directly, bypassing the saved preference, since there is no language switcher to display, the user cannot change it.

Translation loading:

English strings (strings_en.json) are bundled eagerly and available immediately. All other locale files load lazily via import.meta.glob. When loading a non-English locale, missing keys default to English. Empty strings from Transifex (untranslated entries) are removed during normalization so they don't override the English fallback (we use translator mode when downloading languages from Transifex, so this shouldn't happen, but it's a good safety net).

If no translation file exists for a requested locale, resolveLocale tries the base language first (e.g. en for en-AU) and falls back to English if neither is found.

PrimeVue locale

PrimeVue's built-in components (e.g. date pickers) have their own locale strings. These are updated in sync with the active locale using the primelocale package. On each locale change, primevue.config.locale is updated with the matching PrimeVue locale (with the same base-language fallback logic).

Form's language switcher

The language switcher is in the FormHeader, which exposes it as a "change language" event.
When the user picks a language from the form's own language switcher, useLocale updates the UI locale, the PrimeVue locale, and calls formRef.value.setLanguage() on the engine root node, all in one call.

Why `@formatjs/intl` directly, and not `vue-intl` or `vue-i18n`?

The vue-i18n was ruled out because it does not support ICU message format natively, it requires additional parsing processes. ICU is essential here for pluralization, which @formatjs/intl handles out of the box.

The vue-intl is a thin Vue wrapper around @formatjs/intl. Rather than going through the wrapper, the composable uses @formatjs/intl directly and exposes it via a custom useLocale composable. This was necessary for three reasons:

  1. A language change must update the Vue UI strings (via @formatjs/intl), PrimeVue's internal component strings (via primelocale + primevue.config.locale), and the ODK XForms engine (via formRef.value?.setLanguage()). The vue-intl only handles the first of these. We still need a composable to handle the other two, so vue-intl wasn't adding any worth. So a single composable that owns all three updates is the natural fit.

  2. Transifex exports STRUCTURED_JSON with a nested format { "key": { "string": "Translated text" } }, rather than the flat { "key": "Translated text" } that ICU libraries expect. The normalizeMessages function handles this flattening on the fly. This step sits inside the composable, before messages are handed to @formatjs/intl.

  3. The vue-intl follows the global Vue plugin pattern. The useLocale composable in this PR follows Vue 3 conventions: it is a self-contained, reactive unit of logic that can be instantiated at the form root and composed with the rest of the form-scoped state already managed via provide/inject.

Why `STRUCTURED_JSON` format on Transifex?

Collect and Central uses developer comments, which are a feature of Transifex. These comments are shown in Transifex's UI while the translator works, providing additional context. For this, we use Transifex's STRUCTURED_JSON format that wraps each string value in an object ({ "string": "...", "developer_comment": "..." }). As explained before, the normalizeMessages function flats this to a proper ICU MessageFormat (key -> value)

What's the bundle size after the translation work?
  • it's about 100kB
    • Importing all languages of PrimeLocale for now, regional locale is valuable (e.g. not all Spanish-speaking countries use the same number and date format)
    • Note that *.i18n.json files are not bundled, since they are not explicitly imported anywhere and not part of /src (Vite discards them).
    • The /locales/strings_en.js is bundled, which is the minimum WF needs to display readable labels.

Before:

> dist/MapBlock-D_XJw92T.js      956.23 kB     │ gzip: 213.94 kB
> dist/index.js                                  1,743.54 kB  │ gzip: 731.75 kB
> dist/index-B9tMTkVK.js               2,307.65 kB │ gzip: 554.48 kB

After:

> dist/strings_es-Dq2x9mTl.js     17.82 kB         │ gzip:   3.88 kB
> dist/MapBlock-BihGaAC5.js      957.73 kB      │ gzip: 213.74 kB
> dist/index.js                                 1,743.54 kB  │ gzip: 731.75 kB
> dist/index-pWODZAyT.js            2,737.17 kB   │ gzip: 654.26 kB

How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?

They can see the UI translated and enter dates in the calendar using their language's format. The rest of the form should work as expected.

Do we need any specific form for testing your changes? If so, please attach one.

Use forms in Demo app

What's changed

  • Adds script to generate the strings_en.json file for Transifex.
  • Adds a composable to manage language settings and translate strings.
  • Updates all UI strings to use ICU Message Format for better translatability.
  • Adds documentation around translations.

Following steps

  • Once merged, I'll set up Transifex to automatically read locales/strings_en.json daily, which is configured in the project settings on their website, this is the same process that Central follows.
  • Refactor default validation messages that are currently hardcoded in the engine (required field, constraint failure) so they fall under the responsibility of the Vue client and can be translated.
  • Refactor the default language detection in the engine so that the Vue client can know which language was set as default by the Form designer, allowing it to determine the language to display properly.
    • The order for determining language is: check LocalStorage (user-selected form language), then the default language by design, followed by the browser language matching the form language, and finally the form's first language.
    • Because of this gap, the cases failing are: A, C, H, K, V.

@changeset-bot
Copy link

changeset-bot bot commented Mar 23, 2026

🦋 Changeset detected

Latest commit: 8d68187

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@getodk/xforms-engine Minor
@getodk/web-forms Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@latin-panda latin-panda marked this pull request as ready for review March 25, 2026 19:32
@latin-panda latin-panda requested a review from garethbowen March 25, 2026 19:54
Comment on lines +200 to +201
const formLanguage =
findSavedFormLanguage(langs) ?? findBrowserFormLanguage(langs) ?? langs[0]!;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the next PR, this will be:

Suggested change
const formLanguage =
findSavedFormLanguage(langs) ?? findBrowserFormLanguage(langs) ?? langs[0]!;
const formLanguage =
findSavedFormLanguage(langs)
?? findDefaultLang(langs) <--- missing piece
?? findBrowserFormLanguage(langs)
?? langs[0]!;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced this is the right order. For example if "en" is the default lang, but my browser is "en-NZ" I still want my dates formatted my way. However I do realise it would be weird if the primevue components were translated to one language and the form components were in another.

Perhaps we should use the navigator language if it starts with the form default, so I can use en-NZ if the form default is just en?

}

if (!navigator.geolocation) {
// TODO: translations
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not UI text. Only console logging

@latin-panda
Copy link
Collaborator Author

latin-panda commented Mar 25, 2026

@garethbowen I tried to add as much details as I remembered in the PR description, let me know any questions.

I've included a section with follow-ups at the end of the PR description. I plan to resolve these two in the next PR since this one is already quite large.

Copy link
Collaborator Author

@latin-panda latin-panda Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered splitting this file because some parts are beginning to group, but I chose not to since it's still manageable. The logic is only used here, and I preferred to keep everything in one place for now.

Copy link
Collaborator

@garethbowen garethbowen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great leap forward, thanks!

Comment on lines +200 to +201
const formLanguage =
findSavedFormLanguage(langs) ?? findBrowserFormLanguage(langs) ?? langs[0]!;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced this is the right order. For example if "en" is the default lang, but my browser is "en-NZ" I still want my dates formatted my way. However I do realise it would be weird if the primevue components were translated to one language and the form components were in another.

Perhaps we should use the navigator language if it starts with the form default, so I can use en-NZ if the form default is just en?

const isFormEditMode = ref(false);
provide(IS_FORM_EDIT_MODE, readonly(isFormEditMode));
const { setLanguage, formatMessage } = useLocale(computed(() => state.value.root));
provide(FORMAT_MESSAGE, formatMessage);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced that using provide/inject is necessary here. Why go this route instead of each component just importing the formatMessage function directly?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because of Vue reactivity. The formatMessage function wraps a reactive currentIntl (shallowRef). So, when the locale changes and currentIntl updates, Vue recognizes it needs to re-render the components using it.
If we import this function directly into each child component, Vue wouldn't be able to track it and re-render components.

One way is to pass down the function as a prop to each child component ( <FormQuestion :format-message="formatMessage">). The other way is to use provide/inject to supply it to those who need it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've found a cleaner solution using plugins. Based on the plugin docs (which conveniently use a translation plugin as the example): https://vuejs.org/guide/reusability/plugins.html#writing-a-plugin

Instead of providing/injecting the translation function, if you provide a ref to the language string, then the plugin updates reactively. I've tested a basic version that seems to work and looks like this...

import { type App } from "vue";

export default {
  install: (app:App, options) => {
    app.config.globalProperties.$translate = (key:string) => {
      if (options.lang?.value === 'French') {
        return 'bonjour ' + key;
      } else if (options.lang?.value === 'Español') {
        return 'hola ' + key;
      } else {
        return 'hello ' + key;
      }
    }
  }
}

Then in the webFormsPlugin,

  const lang = ref('en');
  app.provide('lang', lang);
  app.use(translateplugin, {lang: lang});

Then whenever you want to change the language you inject the ref and update the value...

const lang:Ref<string> = inject('lang')!;
lang.value = language;

Then finally to output the string you don't need to inject anything because it's a globally registered plugin:

{{ $translate('greetings.hello') }}

I would consider calling it $t but whatever works.

This appears to work, but let me know if I've missed something!

}

const props = defineProps<FormLoadErrorProps>();
const formatMessage: FormatMessage = inject(FORMAT_MESSAGE)!;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's come up with a shorter naming convention for formatMessage because it's so widely used.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most common name I've seen in libraries (like vue-i18n) is t(...). I renamed it. Let me know what you think :)

…ranslations-and-primevue-locales

# Conflicts:
#	packages/web-forms/src/components/form-elements/upload/UploadImageHeader.vue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add translation support for UI components based on browser locale

2 participants