Skip to content

Added messageFormatter into i18nBuilder and support for choiceFormat#942

Open
vinckobb wants to merge 2 commits intokeycloakify:mainfrom
vinckobb:main
Open

Added messageFormatter into i18nBuilder and support for choiceFormat#942
vinckobb wants to merge 2 commits intokeycloakify:mainfrom
vinckobb:main

Conversation

@vinckobb
Copy link
Copy Markdown

@vinckobb vinckobb commented Oct 22, 2025

Fixes #938 by adding support for messageFormatter function into i18n using withMessageFormatter in i18nBuilder, and adding support for choiceFormat message format used in keycloak translations

Summary by CodeRabbit

  • New Features

    • Added ICU-style choice message formatting and exposed a formatChoiceMessage API.
    • Introduced an optional custom message formatter configurable via the i18n builder/creation flow.
  • Tests

    • Added comprehensive tests covering choice formatting, nested placeholders, and various argument scenarios.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 22, 2025

Walkthrough

Adds a ChoiceFormat parser formatChoiceMessage and threads an optional messageFormatter through the i18n builder/factory chain, exposing a withMessageFormatter builder method and using the custom formatter when provided (falling back to the ChoiceFormat implementation otherwise).

Changes

Cohort / File(s) Summary
ChoiceFormat Implementation
src/login/i18n/noJsx/formatMessageChoiceFormat.ts
New module implementing ICU/Java-style choice parsing and placeholder substitution; exports formatChoiceMessage(message: string, args?: any[]): string.
No-JSX i18n Factory
src/login/i18n/noJsx/getI18n.tsx
Added MessageFormatter type, optional messageFormatter param to createGetI18n and translation factory; uses provided formatter or falls back to formatChoiceMessage.
No-JSX Builder
src/login/i18n/noJsx/i18nBuilder.ts
Threaded MessageFormatter generic through I18nBuilder, added withMessageFormatter() method, and passed formatter into createGetI18n during build.
Public Exports (noJsx)
src/login/i18n/noJsx/index.ts
Re-exported formatChoiceMessage.
With-JSX Builder
src/login/i18n/withJsx/i18nBuilder.ts
Mirrored builder changes: added MessageFormatter generic, withMessageFormatter() method, and propagated formatter through chain.
useI18n Hook
src/login/i18n/withJsx/useI18n.tsx
Added MessageFormatter export and optional messageFormatter param; forwards formatter to createGetI18n.
Unit Tests
test/login/formatMessageChoiceFormat.spec.ts
New Vitest suite covering choice parsing, placeholder substitution, edge cases and nested patterns.
Type-level Test / Example
test/login/i18n.typelevel-spec.ts
Added builder usage demonstrating .withMessageFormatter(...) in the fluent chain.

Sequence Diagram(s)

sequenceDiagram
    participant Dev as Developer
    participant Builder as I18nBuilder
    participant GetI18n as createGetI18n
    participant Factory as translation factory
    participant Resolver as resolveMsg

    Note over Dev,Builder: Build without custom formatter
    Dev->>Builder: .build()
    Builder->>GetI18n: createGetI18n({ messageFormatter: undefined })
    GetI18n->>Factory: createTranslationFunctions({ messageFormatter: undefined })
    Factory->>Resolver: resolveMsg(message,args)
    Resolver->>Resolver: formatChoiceMessage(message,args)
    Resolver-->>Dev: formatted message

    Note over Dev,Builder: Build with custom formatter
    Dev->>Builder: .withMessageFormatter(customFn).build()
    Builder->>GetI18n: createGetI18n({ messageFormatter: customFn })
    GetI18n->>Factory: createTranslationFunctions({ messageFormatter: customFn })
    Factory->>Resolver: resolveMsg(message,args)
    Resolver->>Resolver: customFn(message,args)
    Resolver-->>Dev: formatted message
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I hopped through braces, pipes, and signs,
Chose the right branch by numeric lines.
A builder now accepts my art—
Pass me a formatter, and I'll play my part.
Cheers to clearer messages, one carrot at a time! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "Added messageFormatter into i18nBuilder and support for choiceFormat" accurately reflects the main objectives of the changeset. The changes introduce a withMessageFormatter method to the I18nBuilder to allow custom message formatting, and implement formatChoiceMessage to support Java-style ChoiceFormat patterns. The title is concise, clear, and specific enough that a teammate scanning the history would understand the primary changes without additional context.
Linked Issues Check ✅ Passed The code changes comprehensively address the requirements from issue #938. The PR implements formatChoiceMessage to handle Java-style ChoiceFormat patterns [#938], adds the withMessageFormatter method to both noJsx and withJsx I18nBuilder implementations [#938], threads the messageFormatter through the i18n creation flow (getI18n, createI18nTranslationFunctionsFactory) [#938], and ensures the formatter is invoked in the resolveMsg path when provided while falling back to formatChoiceMessage otherwise [#938]. The implementation preserves existing behavior when no custom formatter is provided, and test coverage validates both the new formatChoiceMessage function and the builder integration [#938].
Out of Scope Changes Check ✅ Passed All code changes are directly related to implementing the stated objectives from issue #938. The new formatMessageChoiceFormat.ts module implements ChoiceFormat parsing, modifications to getI18n.tsx and both i18nBuilder variants add messageFormatter support and threading, the index.ts re-export exposes the public API, and all test files validate the new functionality. No changes appear to address unrelated concerns or introduce functionality outside the scope of adding messageFormatter support and ChoiceFormat handling.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dbe2ec6 and bb2cd20.

📒 Files selected for processing (5)
  • src/login/i18n/noJsx/formatMessageChoiceFormat.ts (1 hunks)
  • src/login/i18n/noJsx/getI18n.tsx (6 hunks)
  • src/login/i18n/noJsx/i18nBuilder.ts (5 hunks)
  • src/login/i18n/withJsx/i18nBuilder.ts (5 hunks)
  • test/login/formatMessageChoiceFormat.spec.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/login/formatMessageChoiceFormat.spec.ts
  • src/login/i18n/noJsx/i18nBuilder.ts
🧰 Additional context used
🧬 Code graph analysis (3)
src/login/i18n/noJsx/formatMessageChoiceFormat.ts (1)
src/login/i18n/noJsx/index.ts (1)
  • formatChoiceMessage (2-2)
src/login/i18n/withJsx/i18nBuilder.ts (3)
src/login/i18n/noJsx/i18nBuilder.ts (2)
  • I18nBuilder (11-77)
  • i18nBuilder (151-154)
src/login/i18n/noJsx/getI18n.tsx (1)
  • MessageFormatter (18-18)
src/login/i18n/withJsx/useI18n.tsx (2)
  • MessageFormatter_I18n (19-19)
  • createUseI18n (21-147)
src/login/i18n/noJsx/getI18n.tsx (1)
src/login/i18n/noJsx/formatMessageChoiceFormat.ts (1)
  • formatChoiceMessage (106-140)
🪛 ast-grep (0.39.6)
src/login/i18n/noJsx/formatMessageChoiceFormat.ts

[warning] 94-94: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(\\{${i + startIndex}\\}, "g")
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🔇 Additional comments (11)
src/login/i18n/noJsx/formatMessageChoiceFormat.ts (5)

6-15: LGTM!

The brace-matching logic correctly handles nested braces using a depth counter.


22-38: LGTM!

The splitting logic correctly handles nested braces, ensuring that "|" characters inside nested choice patterns are not treated as option separators.


46-65: Comparison semantics are now correct.

The fix from the previous review has been applied correctly - # now uses <= and < uses >, matching Java ChoiceFormat semantics.

Note: NaN values (e.g., from Number(undefined) or invalid strings) will fall through to the fallback option, which is reasonable behavior. If you want more explicit handling, you could add || Number.isNaN(value) to the check at line 55, but the current logic is acceptable.


75-98: Backward compatibility preserved as intended.

Based on the previous review discussion, this offset mechanism is necessary for compatibility with Keycloak's message format. The static analysis warning about RegExp from variable is theoretical in this case since the variable is a simple numeric index.


106-140: LGTM!

The main formatting logic correctly:

  • Finds and processes all choice patterns using regex
  • Handles nested braces via findMatchingBrace
  • Tracks the minimum placeholder index for consistent offset behavior
  • Falls back gracefully when closing braces are missing (line 123)
  • Processes remaining simple placeholders after choice pattern substitution
src/login/i18n/noJsx/getI18n.tsx (3)

16-18: LGTM!

The MessageFormatter type is well-defined with appropriate flexibility for argument types.


47-66: LGTM!

The generic parameter and optional messageFormatter parameter are correctly added, maintaining backward compatibility while enabling custom formatter support.


310-360: Excellent error handling for custom formatter.

The try-catch wrapper around the custom messageFormatter (lines 352-359) with fallback to formatChoiceMessage is a good defensive pattern that ensures robustness even if user-supplied formatters throw errors.

src/login/i18n/withJsx/i18nBuilder.ts (3)

11-77: LGTM!

The MessageFormatter generic parameter is correctly threaded through the builder type with appropriate:

  • Type constraint (extends MessageFormatter_I18n)
  • Default value (never for optional behavior)
  • Propagation through all method return types
  • Type-safe exclusion via the ExcludedMethod pattern

79-149: LGTM!

The implementation correctly:

  • Threads messageFormatter through all builder methods
  • Implements the new withMessageFormatter method following the immutable builder pattern
  • Forwards the formatter to the final createUseI18n call

The builder chain properly maintains the formatter across all method calls.


151-154: LGTM!

The default i18nBuilder export correctly omits the optional messageFormatter, maintaining backward compatibility while allowing users to add it via the new withMessageFormatter() method.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@vinckobb vinckobb marked this pull request as ready for review October 22, 2025 10:18
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/login/i18n/noJsx/i18nBuilder.ts (1)

112-129: Bug: messageFormatter lost across builder steps

withThemeName/withExtraLanguages/withCustomTranslations don't forward params.messageFormatter; chaining drops the formatter unless set last.

         withThemeName: () =>
             createI18nBuilder({
                 extraLanguageTranslations: params.extraLanguageTranslations,
                 messagesByLanguageTag_themeDefined:
-                    params.messagesByLanguageTag_themeDefined as any
+                    params.messagesByLanguageTag_themeDefined as any,
+                messageFormatter: params.messageFormatter
             }),
         withExtraLanguages: extraLanguageTranslations =>
             createI18nBuilder({
                 extraLanguageTranslations,
                 messagesByLanguageTag_themeDefined:
-                    params.messagesByLanguageTag_themeDefined as any
+                    params.messagesByLanguageTag_themeDefined as any,
+                messageFormatter: params.messageFormatter
             }),
         withCustomTranslations: messagesByLanguageTag_themeDefined =>
             createI18nBuilder({
                 extraLanguageTranslations: params.extraLanguageTranslations,
-                messagesByLanguageTag_themeDefined
+                messagesByLanguageTag_themeDefined,
+                messageFormatter: params.messageFormatter
             }),
🧹 Nitpick comments (8)
test/login/i18n.typelevel-spec.ts (1)

128-149: Type-level coverage for withMessageFormatter is good; add a tiny runtime check

Consider a small runtime spec ensuring the custom formatter is actually invoked (e.g., stub that records calls).

Would you like me to add an integration test that builds i18n with withMessageFormatter and asserts the stub was called?

test/login/formatMessageChoiceFormat.spec.ts (1)

1-310: Add boundary tests to match Java ChoiceFormat semantics

Please add:

  • ≤ semantics for “#”: e.g., "3#≤3|3< >3" with values 2, 3, 4.
  • Interval coverage: "5# ≤5|10# ≤10|10< >10" with value 7 should pick the ≤10 branch.
  • Negative/decimal values (e.g., -1, 2.5).
  • Sparse/large indices (e.g., {0} and {10}) to ensure placeholder mapping by explicit index.

I can draft these additional cases if helpful.

src/login/i18n/noJsx/formatMessageChoiceFormat.ts (1)

106-139: Minor: simplify by dropping startIndex entirely after refactor

After the above change, startIndex is unused in formatChoiceMessage; you can remove it for clarity.

src/login/i18n/withJsx/useI18n.tsx (1)

24-26: Ergonomics: default generic = never might block passing messageFormatter

With MessageFormatter defaulting to never, callers of createUseI18n can’t pass messageFormatter unless they specify the generic. If this factory is public, consider defaulting to MessageFormatter_I18n instead, or ensure builder paths always provide the generic.

If you prefer the current constraint, please confirm that external consumers don’t call createUseI18n directly.

Also applies to: 38-41, 118-118

src/login/i18n/noJsx/getI18n.tsx (2)

18-18: Unify and tighten MessageFormatter arg type

Use unknown over any to avoid unsoundness and align across API.

-export type MessageFormatter = (message: string, args: (any | undefined)[]) => string;
+export type MessageFormatter = (message: string, args: (unknown | undefined)[]) => string;

352-357: Guard custom formatter; fallback on error

A throwing formatter will break rendering. Catch and fallback to default.

-            if (messageFormatter) {
-                return messageFormatter(message, args);
-            }
-
-            return formatChoiceMessage(message, args);
+            if (messageFormatter) {
+                try {
+                    return messageFormatter(message, args);
+                } catch (e) {
+                    console.warn("keycloakify:i18n messageFormatter error; falling back to ChoiceFormat", e);
+                }
+            }
+            return formatChoiceMessage(message, args);
src/login/i18n/noJsx/i18nBuilder.ts (1)

62-70: Use the shared formatter type alias to avoid drift

Accept the aliased type instead of re-stating the function signature.

-        withMessageFormatter: (
-            formatter: (message: string, args: (string | undefined)[]) => string
-        ) => I18nBuilder<
+        withMessageFormatter: (
+            formatter: MessageFormatter_I18n
+        ) => I18nBuilder<
src/login/i18n/withJsx/i18nBuilder.ts (1)

62-70: Type consistency: accept the alias for formatter

Mirror the central alias to prevent signature drift and ease refactors.

-        withMessageFormatter: (
-            formatter: (message: string, args: (string | undefined)[]) => string
-        ) => I18nBuilder<
+        withMessageFormatter: (
+            formatter: MessageFormatter_I18n
+        ) => I18nBuilder<
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e1b99ef and dbe2ec6.

📒 Files selected for processing (8)
  • src/login/i18n/noJsx/formatMessageChoiceFormat.ts (1 hunks)
  • src/login/i18n/noJsx/getI18n.tsx (6 hunks)
  • src/login/i18n/noJsx/i18nBuilder.ts (6 hunks)
  • src/login/i18n/noJsx/index.ts (1 hunks)
  • src/login/i18n/withJsx/i18nBuilder.ts (5 hunks)
  • src/login/i18n/withJsx/useI18n.tsx (4 hunks)
  • test/login/formatMessageChoiceFormat.spec.ts (1 hunks)
  • test/login/i18n.typelevel-spec.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
src/login/i18n/noJsx/formatMessageChoiceFormat.ts (1)
src/login/i18n/noJsx/index.ts (1)
  • formatChoiceMessage (2-2)
src/login/i18n/withJsx/useI18n.tsx (1)
src/login/i18n/noJsx/getI18n.tsx (2)
  • MessageFormatter (18-18)
  • createGetI18n (47-308)
src/login/i18n/noJsx/i18nBuilder.ts (3)
src/login/i18n/withJsx/i18nBuilder.ts (2)
  • I18nBuilder (11-77)
  • i18nBuilder (151-154)
src/login/i18n/noJsx/getI18n.tsx (2)
  • MessageFormatter (18-18)
  • createGetI18n (47-308)
src/login/i18n/withJsx/useI18n.tsx (1)
  • MessageFormatter_I18n (19-19)
test/login/i18n.typelevel-spec.ts (2)
src/login/i18n/noJsx/i18nBuilder.ts (1)
  • i18nBuilder (148-151)
src/login/i18n/withJsx/i18nBuilder.ts (1)
  • i18nBuilder (151-154)
src/login/i18n/noJsx/getI18n.tsx (1)
src/login/i18n/noJsx/formatMessageChoiceFormat.ts (1)
  • formatChoiceMessage (106-140)
test/login/formatMessageChoiceFormat.spec.ts (2)
src/login/i18n/noJsx/formatMessageChoiceFormat.ts (1)
  • formatChoiceMessage (106-140)
src/login/i18n/noJsx/index.ts (1)
  • formatChoiceMessage (2-2)
src/login/i18n/withJsx/i18nBuilder.ts (3)
src/login/i18n/noJsx/i18nBuilder.ts (2)
  • I18nBuilder (11-77)
  • i18nBuilder (148-151)
src/login/i18n/noJsx/getI18n.tsx (1)
  • MessageFormatter (18-18)
src/login/i18n/withJsx/useI18n.tsx (2)
  • MessageFormatter_I18n (19-19)
  • createUseI18n (21-147)
🪛 ast-grep (0.39.6)
src/login/i18n/noJsx/formatMessageChoiceFormat.ts

[warning] 94-94: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(\\{${i + startIndex}\\}, "g")
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🔇 Additional comments (3)
src/login/i18n/noJsx/index.ts (1)

2-2: Public re‑export looks good

Exposing formatChoiceMessage from noJsx is fine and keeps API surface coherent.

src/login/i18n/withJsx/useI18n.tsx (2)

4-4: Good: messageFormatter is threaded into createGetI18n

Importing and passing MessageFormatter through ensures parity with noJsx path.


19-20: Type re-export is helpful

Re-exporting MessageFormatter improves DX for consumers importing from withJsx.

Comment thread src/login/i18n/noJsx/formatMessageChoiceFormat.ts
Comment thread src/login/i18n/noJsx/formatMessageChoiceFormat.ts
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.

i18n does not support java ChoiceFormat

2 participants