Skip to content

feat(#332): add translation support#727

Draft
latin-panda wants to merge 2 commits intomainfrom
add-translations-webforms
Draft

feat(#332): add translation support#727
latin-panda wants to merge 2 commits intomainfrom
add-translations-webforms

Conversation

@latin-panda
Copy link
Collaborator

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

Closes #332

TODO

  • Add project in https://app.transifex.com/getodk/
  • Add more test coverage
  • Test more cases
  • Finish translations english, spanish, indonesian
  • Add changeset
  • Document translation guide and best practices

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?

Browsers with different default languages

See message in error banner translated

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

The objective is to implement a scalable, lightweight internationalization that:

  • Allows host applications to fetch (Transifex) and provide translation files.
  • Supports complex variables and plurals using standard syntax.
  • Keeps the Web Forms bundle size minimal for low-end devices.
  • Provides contextual information (developer_comment) to translators. Just as Central and Collect do.

Boundary definition:
Web Forms will manage its own internal locale state and browser detection, but it will remain agnostic to how translation files are retrieved (Transifex).

  • Web forms component
    • Responsible for detecting the user's preferred language via standard browser APIs (navigator.language) upon initialization
    • Owns the active locale state and the language-switcher UI dropdown (there's an open question about language-switcher below).
    • Exposes a required prop/callback (e.g., fetchTranslations(localeCode)) that it triggers when it mounts or when the user switches languages.
  • The host app (Central)
    • The data provider. It implements the fetchTranslations function passed to Web Forms, handling the actual network request to fetch the appropriate JSON translation file from the backend or Transifex.

Translation format:
Web Forms will utilize a nested JSON structure with strings and developer comments to align with Central and Collect, following ICU Message Format syntax for handling variables and plurals.

It will include TypeScript interfaces to ensure a valid structure and documentation to guide contributors.

Library:
To parse the ICU strings, Web Forms will use intl-messageformat (from FormatJS) rather than heavier frameworks.

  • It's lightweight, 9.6kB gzipped.
  • Strictly follows ECMA-402. It offloads the heavy lifting of pluralization rules to the browser's native Intl API.
  • Transifex natively understands ICU MessageFormat
  • It has a strong community that keeps it updated with the latest ECMA-402.
Other libraries considered
  • vue-i18n: Adds ~15-20kb to the bundle size. It uses block that embeds strings into the compiled component (like Central does), making components less readable (I'm not a fan)
  • typesafe-i18n: Fast and lightweight (~1.2kb), but translations must be in a custom syntax. It doesn't support Transifex natively; implementing that would require complex, custom intermediate parsers.
  • Native Intl.PluralRules Wrapper: Building custom code around browser APIs without dependencies places the burden of maintenance on us.

Implementation details:

Each component that needs translations will have a sibling file: <component>.i18n.ts
This separation maintains component readability and focus on code. It is also extensible; if additional languages need to be added in the future, the main component remains unaffected. And good for tree-shaking support (more on that below).

There's a script (package/web-forms/script/extract-translations.js) that dynamically gets all <component>.i18n.ts and compiles them into a single strings_en.json file for Transifex upload. Maybe the upload can be automated as part of the release step in Github Actions.

It uses one Vue composable to initialize the internalization and fetch the translation files from the Host app. Another Vue composable keeps the locale state and executes the intl-messageformat parser.

Components will access strings via dot-notation: <span>{{ t('errors.invalidInputs', { count: 3 }) }}</span>

Possible future considerations (not part of this delivery)

  • Have a destructure-translations.js script that takes the downloaded translated JSON from Transifex and maps the localized strings back to their respective component i18n, allowing the application to easily serve them on request (tree-shaking for all languages, not just the default language).
  • Path to PWA (If): No longer rely only on the host app's translations, but dynamically import the generated *.i18n.[locale].ts directly. The current structure ensures zero refactoring in the Vue templates/components.

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?

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

What's changed

Open Question:

If switching language from the dropdown
Screenshot 2026-03-11 at 2 44 51 AM

How to get the locale code if form definitions don't provide the standard:

@changeset-bot
Copy link

changeset-bot bot commented Mar 10, 2026

⚠️ No Changeset found

Latest commit: 36b5255

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@latin-panda latin-panda changed the title feat(#332): Add translations webforms feat(#332): add translation support Mar 10, 2026
@latin-panda latin-panda requested a review from garethbowen March 10, 2026 19:47
@latin-panda
Copy link
Collaborator Author

@garethbowen There's still plenty to do, but the PR is at a good point to share for feedback.
What are your thoughts on this approach? I've included an explanation in the description box.


const formPreviewState = ref<FormPreviewState>();

// TODO: REMOVE THIS MOCK. Temporal for testing 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.

I will revert the changes once the task is completed.

return [namespace, module.defaultStrings];
});

const resolvedImports = await Promise.all(imports);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think you need to do await Promise.all here because you're awaiting in the .map right?

const files = await findI18nFiles(srcDir);
const dictionary = await buildAggregatedDictionary(files);

await mkdir(outputDir, { recursive: true });
Copy link
Collaborator

Choose a reason for hiding this comment

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

Will the outputdir be ignored or committed? If it's ignored, let's call it something more obvious, like build/locales or something?

}
};

void runExtraction();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Central has done a lot of work with scripts to do this too - is there any benefit in trying to reuse their effort?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Still different, this is a lot simpler

import type { TranslationDictionary } from '@/lib/i18n/i18n-context.ts';

export const defaultStrings: TranslationDictionary = {
title: {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Probably not a good label for an error message.

if (violationLength === 0) return '';
else if (violationLength === 1) return '1 question with error.';
else return `${violationLength} questions with errors.`;
if (violationLength === 0) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Remove TODO :D

developer_comment:
'Message shown in the error banner when multiple questions have validation errors. {count} is the number of errors.',
},
},
Copy link
Collaborator

Choose a reason for hiding this comment

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

It'd be better to combine these into one message with logic to handle the plurals.

) => Promise<Record<string, TranslationDictionary>>;

export function useI18nSetup(fetchTranslations?: FetchTranslationsCallback) {
const browserLocale = navigator.language?.split('-')[0];
Copy link
Collaborator

Choose a reason for hiding this comment

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

It feels like we're having to implement things that a property translation lib should do for us. This is one example, where maybe it's good enough, but we're missing out on features you get for free using a library 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.

The vue-i18n doesn't determine the language, instead, Central retrieves it from navigator.language as we do here, and also from localStorage when their app language dropdown is changed.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Right but it does do proper locale fallback so you (a) don't need to split the language from the navigator, and (b) get the flexibility of being able to use the second part for more flexibility, (eg: en-NZ: "initialising" vs en-US: "initializing").

};

return { t };
}
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 nervous about providing custom code and caching, and would lean towards a more complete library such as vue-i18n which does more for us. Plus we have the benefit of standardisation with central and benefiting from things they've learned and tools they've built.

Copy link
Collaborator Author

@latin-panda latin-panda Mar 11, 2026

Choose a reason for hiding this comment

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

Yes, I understand the concern about the code and caching (I can improve the implementation to make it more robust).

I have two takes on the vue-i18n vs. intl-messageformat choice:

1. WF as a separate project using standard ICU MessageFormat
(This was my initial assumption for the design explained in the PR description)

The deeper I look into vue-i18n integration with Transifex, the less it makes sense for this project. Central uses a massive custom build script (~750 lines) to make it work with Transifex and ICU plurals (+ other utils around the code). They had to write a custom parser to rip apart ICU strings and convert them into vue-i18n’s pipe syntax. Even though that code is mature, I’m uncomfortable importing that complexity into the Web Forms repo. Plus, since we fetch translations dynamically, using vue-i18n with ICU would mean shipping that compiler code to the browser (I need to test this).

Our bundle size is already big. The vue-i18n plus the custom scripts adds >20kb, while intl-messageformat is only 9kb. We could save that space for other features :)

If this works well, Central might even be able to adopt this approach later to align better with native APIs and remove a lot of the custom code they need to maintain.

2. WF has a strong dependency on Central and WF uses the vue-i18n format

In this case, Central would handle the conversion of ICU MessageFormat and send the vue-i18n format directly to Web Forms. But we’d still have a larger bundle size than intl-messageformat, and we'd be "locked in" to Central's decisions on format.

I’m going to set up a meeting with the Central team to understand their struggles with Transifex and how it impacts their vue-i18n usage. Let me know if you still feel strongly about aligning with Central on this specific library and we can plan how to share code, etc.

@garethbowen
Copy link
Collaborator

The open question is really interesting. We could add to the spec to make this deterministic, eg: label::English::en but that'll take a while to gain traction. Maybe make a best guess by splitting the lang by whitespace/punctuation and looking for a code that matches?

@lognaturel
Copy link
Member

Great to see this moving forward! I appreciate the thought given to keeping the default bundle small and leaving open the possibility of Central users supplying their own translations.

I haven't given any thought to vue-i18n vs. intl-messageformat.

add to the spec to make this deterministic

https://docs.getodk.org/form-language/ -- we do document adding the IANA language code in parentheses and XLSForm warns when it's not included.

enketo/enketo#1082 has the algorithm we had come up with for determining form and UI language in Enketo. I think eventually we would like the same in Web Forms but it can be a follow-up issue to refine (e.g. if form-level default language is currently ignored).

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

3 participants