Skip to content

RFC: Introduce i18n to the renderer (chrome strings + locale-aware dates)#1995

Open
oliveregger wants to merge 12 commits into
aehrc:mainfrom
ahdis:feat/i18n-renderer-strings
Open

RFC: Introduce i18n to the renderer (chrome strings + locale-aware dates)#1995
oliveregger wants to merge 12 commits into
aehrc:mainfrom
ahdis:feat/i18n-renderer-strings

Conversation

@oliveregger

Copy link
Copy Markdown
Contributor

Summary — proposal / RFC seeking direction

We'd like to introduce internationalization (i18n) to @aehrc/smart-forms-renderer and get the core team's feedback on whether this is a direction you'd welcome before we invest further and expand coverage.

Today the renderer hardcodes English UI text and an Australian date format, so there's no way to localize it — e.g. we need Ja/Nein and DD.MM.YYYY for Switzerland (German/French/Italian). This PR is an opt-in, backward-compatible foundation that demonstrates the approach on a first slice (boolean labels + the date subsystem), deliberately kept small so the design can be discussed before scaling it out.

This is intentionally opened as a draft — we're looking for agreement on the architecture and naming, not a merge as-is. Translations are also first-pass and would need native-speaker review.

What's included

  • A locale-driven catalog of renderer-owned UI strings (RendererStrings) with English defaults, configured through the existing rendererConfigStore via a new locale and an optional rendererStrings override — no new config surface or provider.
  • Bundled de-CH / fr-CH / it-CH catalogs authored as JSON translation files.
  • Boolean Yes/No labels sourced from the catalog.
  • Locale-aware dates: input/display format derived from the active locale via Intl, with an optional explicit dateFormat override.
  • Order-aware date validation (supports DD/MM/YYYY, MM/DD/YYYY US, YYYY/MM/DD).
  • Localized, interpolated date validation error messages.
  • Swiss dayjs locale data so the date-picker calendar popup is localized too.
  • Storybook stories (BooleanLocaleDeCH, DateLocaleDeCH) demonstrating the result.
  • Tests: catalog resolution, a typed key guard, a placeholder-consistency guard, format derivation, US ordering, and interpolation.

Usage

buildForm({ questionnaire, rendererConfigOptions: { locale: 'de-CH' } });
// → Ja / Nein, dates as DD.MM.YYYY, German validation messages

// Or override individual strings / the date format directly, independent of locale:
buildForm({ questionnaire, rendererConfigOptions: {
  rendererStrings: { booleanYesLabel: 'Oui', dateFormat: 'MM/DD/YYYY' }
}});

Two layers of i18n (scope of this PR)

We see two distinct concerns and this PR addresses only the first:

  1. Renderer "chrome" — text the renderer itself owns (Yes/No, validation messages, buttons, aria-labels). Lives in code → needs a string catalog. ← this PR
  2. Questionnaire contentitem.text, answerOption.display, titles. This is data, and FHIR already standardizes it via Questionnaire.language + the translation extension on _text. A separate, larger piece we'd tackle next if you're on board with the overall direction.

Key design decisions & reasoning

1. Config-injected catalog, not an i18n framework (i18next/FormatJS/Lingui).
The renderer is a published library. Pulling in a framework would force peer deps, a provider, and a workflow on every consumer. Instead the library ships English defaults + optional bundled locales, and consumers either pass locale or inject a plain Partial<RendererStrings> object — so an app already using i18next/etc. can feed its own translations in without adopting ours. Zero setup for the common case.

2. Bundled catalogs as JSON, not .ts or .po.
JSON is translation-tool friendly (Crowdin/Weblate/Locize/Poedit), needs no runtime parser, and tsc already emits it to lib/. We keep type safety without a framework: values are typed via the Record<string, Partial<RendererStrings>> registry, a unit test guards against unknown keys (typos), and another guards placeholder consistency across locales. .po (via Lingui) would give the best translator tooling but needs a compile step — worth revisiting only if a translation-management workflow is desired.

3. Date format derived from the locale via Intl, not hardcoded per locale.
Intl.DateTimeFormat().formatToParts() yields the correct pattern for every locale (de-CHDD.MM.YYYY, en-USMM/DD/YYYY, ja-JPYYYY/MM/DD) with no bundled locale data and no per-locale import list. An explicit dateFormat override remains as an escape hatch. We deliberately kept the renderer's existing custom parser (rather than switching to MUI X's native field or pure Intl) because it supports FHIR partial dates (YYYY, YYYY-MM) that those alternatives don't handle cleanly.

4. Order-aware validation.
validateThreeMatches previously assumed day-first. It now maps positional parts to day/month/year using the active format, so month-first (US) and other orderings validate correctly — a prerequisite for locale-driven formats.

5. Localized messages via {placeholder} interpolation.
Validation messages became catalog templates (e.g. Input does not match the format {format}.) filled at runtime, so translations stay natural while dynamic values (format, separator) are injected. A test asserts every translation preserves the original placeholders.

6. dayjs Swiss locale imports kept only for the calendar popup.
The field format needs no locale import (Intl). The three dayjs/locale/* imports remain solely to localize the date-picker calendar's month/weekday names for the Swiss locales; consumers wanting this for other locales can import the matching dayjs locale themselves.

Backward compatibility

Fully opt-in. With no locale/rendererStrings, behavior is unchanged: English labels, DD/MM/YYYY, English validation messages. The full renderer test suite passes (2182 tests), including the pre-existing date/boolean tests unchanged.

Open questions we'd love feedback on

  1. Overall appetite — is a locale-driven chrome catalog the direction you'd want, or do you have a preferred i18n strategy in mind?
  2. Naming / placementRendererStrings, locale/rendererStrings on RendererConfig, the i18n/ folder — happy to align with your conventions.
  3. Config reset per build — renderer config currently persists across buildForm calls unless re-passed; we worked around story isolation in the Storybook wrapper only. Would you want buildForm to reset config to defaults per build? (behavioral change, kept out of scope here)
  4. Content layer — interest in the FHIR translation-extension layer (item.text/answerOption) as a follow-up?
  5. Catalog format — JSON acceptable, or would you prefer .po/Lingui with a compile step for a real translation workflow?
  6. Translations — the de/fr/it-CH strings are first-pass and want a native review before shipping.

Happy to iterate, split this up, or realign to fit your roadmap.

🤖 Generated with Claude Code

@oliveregger

Copy link
Copy Markdown
Contributor Author

Tracking issue: #1996 — background/motivation for this RFC.

oliveregger and others added 10 commits July 14, 2026 23:05
Add a locale-driven catalog for renderer-owned UI text ("chrome") plus
locale-aware date formatting/validation, configured via the existing
rendererConfigStore (new `locale` + optional `rendererStrings` override).

- RendererStrings catalog with English defaults; bundled de-CH/fr-CH/it-CH
  catalogs authored as JSON translation files (translator-tool friendly)
- Boolean Yes/No labels sourced from the catalog
- Date input/display format derived from the active locale via Intl (all
  locales, no bundled locale data), with an optional `dateFormat` override
- Order-aware date validation (DD/MM, MM/DD US, YYYY/MM/DD)
- Localized, interpolated date validation error messages
- Swiss dayjs locale data for date-picker calendar localization
- Storybook: de-CH boolean/date stories; wrapper forwards rendererConfigOptions
  and resets config per story for isolation
- Tests: catalog resolution, typed key + placeholder guards, format
  derivation, US ordering, interpolation

Backward compatible: defaults are unchanged (English, DD/MM/YYYY); all
localization is opt-in via config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Describes the two-layer i18n model, the chrome string catalog, locale-aware
dates, key design decisions, and next steps. Companion to aehrc#1995 / aehrc#1996.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the hardcoded validation feedback strings into the RendererStrings
catalog so they resolve to the active locale.

- useValidationFeedback: regex/min-max length/decimal/value/quantity messages
  (interpolated templates for the limit values) + the required and unknown-issue
  fallbacks
- useDateTimeNonEmpty: "Date is required"
- English defaults unchanged; de/fr/it-CH translations added
- Keys guard + placeholder-consistency guard extended to cover the new keys

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the terminology autocomplete feedback messages into the RendererStrings
catalog (min-characters hint, query error, no-results).

- English defaults unchanged; de/fr/it-CH translations added
- Keys guard extended

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the choice/open-choice value-set state messages and the form-body
fallbacks into the RendererStrings catalog.

- "No options available.", "Unable to fetch options...", the terminology-server
  fetch error (interpolated {valueSet}), autocomplete "Fetching results...",
  "Unable to load form", and "Something went wrong here"
- Wired across 8 choice/open-choice field components + the FormBody* containers
  and GroupItemSwitcher
- English defaults unchanged; de/fr/it-CH translations added; guards extended

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the interactive-chrome strings (buttons, tooltips, aria-labels) into the
RendererStrings catalog.

- Clear, Remove item, Next/Previous page, Attach/Remove file, Pick a date,
  Sync successful/with server, the sync-failed tooltip (interpolated {item}),
  Drag row, and the "Form sections" landmark label
- Wired across the clear/remove/page/attachment/date-picker/repopulate/table
  components and the tab-list wrapper
- English defaults unchanged; de/fr/it-CH translations added; guards extended

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the accessibility-label chrome into the RendererStrings catalog.

- "Mandatory field" (required-field screen-reader label)
- "Select row {label}" and "Select all rows in {label}" (table selection),
  with an interpolated {label}
- "Unnamed {type} item" fallback template used by the table selection labels
- English defaults unchanged; de/fr/it-CH translations added; guards extended

Note: the assorted per-component "Unnamed <x>" a11y fallbacks (e.g. group,
slider, checkbox) are only shown when an item has no text and remain a small
optional follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… batch 6)

Move the per-component fallback accessible labels (shown when an item has no
text) into the RendererStrings catalog.

- Generic "Unnamed {type} item" template applied to the type-based fallbacks
- Specific fallbacks: attachment, nested item, open label, radio group,
  checkbox, checkbox list, choice dropdown, group, time field, slider
- Page region labels: "{label} page" / "Unnamed page"
- English defaults unchanged; de/fr/it-CH translations added; guards extended

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ensures the batched i18n changes pass the prettier/prettier lint rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ndererStrings

Adapt the i18n RFC to the maintainer review in aehrc#1996:
- remove the de-CH/fr-CH/it-CH JSON catalogs and the bundledRendererStrings
  registry; consuming apps own their translation files and inject them via
  rendererConfigOptions.rendererStrings
- resolveRendererStrings(overrides) now merges English defaults <- overrides
  only; locale no longer selects strings and drives date formatting and
  calendar localisation exclusively
- remove the dayjs/locale/* imports from BaseRenderer; consumers import
  dayjs locale data themselves for a localised calendar popup
- update the Swiss Storybook stories to demonstrate the injection path with
  an inline catalog (the documented consumer pattern)
- adapt tests and i18n.md accordingly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oliveregger
oliveregger force-pushed the feat/i18n-renderer-strings branch from 0bc08ea to dff2afc Compare July 14, 2026 21:59
oliveregger and others added 2 commits July 15, 2026 09:51
@oliveregger
oliveregger force-pushed the feat/i18n-renderer-strings branch from 39e1632 to 9e896e5 Compare July 15, 2026 08:22
@oliveregger

Copy link
Copy Markdown
Contributor Author

Hi @clinnygee,

I've updated PR #1995 to address your recommendations:

  • Bundled catalogs dropped: the de-CH/fr-CH/it-CH JSON files and the bundledRendererStrings registry are gone. resolveRendererStrings(overrides) now simply merges a consumer-supplied Partial<RendererStrings> onto the English defaults, so the repo no longer hosts or vouches for any translations.
  • locale kept for dates only: it drives the Intl-derived date format and the calendar popup's adapterLocale, and no longer selects strings.
  • dayjs/locale/* imports removed from BaseRenderer: consumers import the locale data themselves; dayjs falls back to English month/weekday names if they don't. (Note: the app's dayjs must dedupe to the same instance as the renderer's — standard npm behaviour, called out in i18n.md.)
  • Storybook stories updated: BooleanLocaleDeCH now demonstrates the injection path with an inline catalog (rendererConfigOptions: { locale: 'de-CH', rendererStrings: { booleanYesLabel: 'Ja', ... } }), and Date.stories.tsx shows the consumer-side import 'dayjs/locale/de-ch' for the calendar. These double as the documented consumer pattern; i18n.md is updated accordingly.
  • Extracted all fixed english strings

As proof of the consumer side, our deployment branch hosts the Swiss catalogs in the app and injects them via a locale picker + Questionnaire.language: https://github.com/ahdis/smart-forms/tree/main-localized/apps/smart-forms-app/src/locales/renderer

You can check here that the actions have successfully run: https://github.com/ahdis/smart-forms/actions?query=branch%3Afeat%2Fi18n-renderer-strings++

Happy to adjust anything else — thanks again for the constructive review!

From our view the next step would be to create a new PR afterwards to support rendering of mutli-language questionaires, mentioned in `i18n.md' as Layer 2.

@clinnygee

Copy link
Copy Markdown
Collaborator

Nice work on this — the string-catalog extraction is clean and the English default path stays behavior-preserving. A few things in the locale-aware date logic I'd want to sort out before merge, mostly around the year-first / non-Western locales this RFC is meant to demonstrate.

1. Month-year handling ignores the locale's token order

Full-date validation is nicely order-aware via orderDateParts, but the month-year path isn't. getMonthYearFormat hardcodes MM<sep>YYYY:

export function getMonthYearFormat(dateFormat: string): string {
  return `MM${getDateSeparator(dateFormat)}YYYY`;
}

and useDateValidation still treats the first part as the month regardless of format:

const matches = input.split(separator);
if (!validateTwoMatches(matches[0], matches[1])) { ... }  // matches[0] assumed to be month

So for the year-first locales the PR advertises (ja-JPYYYY/MM/DD, en-CAYYYY-MM-DD):

  • A user entering the natural 2024/03 gets matches[0] = '2024' → month 2024 > 12 → rejected as an invalid date.
  • parseFhirDateToDisplayDate('2024-03') renders 03/2024 (month-first), inconsistent with the year-first full-date display.

Deriving the month-year format and its part order from getDateTokenOrder(dateFormat) (dropping the D token) — the same way full dates are handled — would keep the two paths consistent.

3. onlySeparatorsRemain accepts a separator-less locale format

const onlySeparatorsRemain = /^[^A-Za-z0-9]*$/.test(format.replace(/DD|MM|YYYY/g, ''));

The regex matches the empty string, so a locale whose short date has no punctuation between tokens is accepted as e.g. YYYYMMDD. getDateSeparator then falls back to /, and validateDateInput / parseInputDateToFhirDate look for a / that never appears in the input — so every date the user types is rejected, instead of falling back to the safe DD/MM/YYYY default. Requiring at least one separator character (i.e. the stripped remainder is non-empty and only separators) would push these through the intended fallback.

4. Contradictory docs for locale

The three descriptions of locale disagree:

  • rendererConfigStore.ts:90"used to select a bundled catalog of renderer strings … Unknown locales fall back to English."
  • rendererConfigStore.ts:130"Drives date formatting and calendar localisation only — it does not select renderer strings."
  • i18n/rendererStrings.ts:31"The locale config option does not select strings — it only drives date formatting and calendar localisation."

The code matches the latter two (nothing reads locale to pick strings), so the @property locale JSDoc at :90 is stale and will lead consumers to expect setting locale to translate the UI chrome.

@clinnygee

Copy link
Copy Markdown
Collaborator

This is what claude has said - she said some other things as well, but those were for wrong. If you could check these out that would be great. After that I am happy to merge it.

Also interested to see what you come with for the rendering of mutli-language questionaires, as the .md file did not go into much detail on it.

Thanks Oliver!

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.

2 participants