Skip to content

Conversation

@StorytellerCZ
Copy link
Member

@StorytellerCZ StorytellerCZ commented Oct 2, 2025

Added the option to add custom message formatters.

Also added more tests and improved performance using new JS API and other known performance boosts like using for of loops instead of forEach.

Custom Message Formatters

Overview

The formatter system allows you to:

  • Use different message format syntaxes (e.g., ICU MessageFormat, i18next format, etc.)
  • Create custom interpolation and pluralization logic
  • Integrate with existing i18n libraries
  • Maintain backward compatibility with the default format

Default Formatter

The package ships with a DefaultMessageFormatter that implements the original universe:i18n format:

Interpolation:

greeting: "Hello {$name}!"
i18n.__('greeting', { name: 'World' }); // "Hello World!"

Pluralization:

items: "no items | one item | {$_count} items"
i18n.__('items', { _count: 0 }); // "no items"
i18n.__('items', { _count: 1 }); // "one item"
i18n.__('items', { _count: 5 }); // "5 items"

Creating a Custom Formatter

To create a custom formatter, implement the MessageFormatter interface:

import { MessageFormatter, FormatterOptions } from 'meteor/universe:i18n';

class MyCustomFormatter implements MessageFormatter {
  format(
    message: string,
    params: Record<string, unknown>,
    locale: string,
    options: FormatterOptions,
  ): string {
    // Your custom formatting logic here
    return formattedMessage;
  }
}

MessageFormatter Interface

interface MessageFormatter {
  /**
   * Formats a translation message with the provided parameters.
   * 
   * @param message - The translation message string
   * @param params - Parameters to interpolate into the message
   * @param locale - The current locale
   * @param options - Additional formatting options
   * @returns The formatted message string
   */
  format(
    message: string,
    params: Record<string, unknown>,
    locale: string,
    options: FormatterOptions,
  ): string;
}

FormatterOptions

The options parameter provides access to configuration:

interface FormatterOptions {
  /** Opening delimiter for variable interpolation (e.g., '{$') */
  open: string;
  
  /** Closing delimiter for variable interpolation (e.g., '}') */
  close: string;
  
  /** Divider for pluralization forms (e.g., ' | ') */
  pluralizationDivider: string;
  
  /** Custom pluralization rules per locale */
  pluralizationRules: Record<string, (count: number) => number>;
}

Using a Custom Formatter

Set your custom formatter using setOptions():

import i18n from 'meteor/universe:i18n';
import { MyCustomFormatter } from './formatters/MyCustomFormatter';

// Create an instance of your formatter
const customFormatter = new MyCustomFormatter();

// Set it as the active formatter
i18n.setOptions({
  messageFormatter: customFormatter,
});

Important: Set the formatter before loading any translations or calling translation functions.

ICU Message Format Example

Here's an example of implementing an ICU MessageFormat formatter using the intl-messageformat library:

1. Install the dependency

npm install intl-messageformat

2. Create the ICU formatter

// formatters/ICUMessageFormatter.ts
import IntlMessageFormat from 'intl-messageformat';
import { MessageFormatter, FormatterOptions } from 'meteor/universe:i18n';

export class ICUMessageFormatter implements MessageFormatter {
  private cache = new Map<string, IntlMessageFormat>();

  format(
    message: string,
    params: Record<string, unknown>,
    locale: string,
    options: FormatterOptions,
  ): string {
    const cacheKey = `${locale}:${message}`;

    // Check cache for compiled message
    if (!this.cache.has(cacheKey)) {
      try {
        this.cache.set(
          cacheKey,
          new IntlMessageFormat(message, locale)
        );
      } catch (error) {
        console.error('ICU MessageFormat compilation error:', error);
        return message;
      }
    }

    try {
      const formatter = this.cache.get(cacheKey)!;
      return formatter.format(params) as string;
    } catch (error) {
      console.error('ICU MessageFormat formatting error:', error);
      return message;
    }
  }
}

3. Use the ICU formatter

import i18n from 'meteor/universe:i18n';
import { ICUMessageFormatter } from './formatters/ICUMessageFormatter';

i18n.setOptions({
  messageFormatter: new ICUMessageFormatter(),
});

4. Write ICU-formatted translations

# en.i18n.yml
_locale: en

# Simple interpolation
greeting: "Hello {name}!"

# Pluralization
items: "{count, plural, =0 {no items} one {# item} other {# items}}"

# Select (gender)
invitation: "{gender, select, male {He is invited} female {She is invited} other {They are invited}}"

# Number formatting
price: "Price: {amount, number, ::currency/USD}"

# Date formatting
appointment: "Your appointment is on {date, date, short}"

5. Use the translations

// Simple interpolation
i18n.__('greeting', { name: 'Alice' }); 
// "Hello Alice!"

// Pluralization
i18n.__('items', { count: 0 }); // "no items"
i18n.__('items', { count: 1 }); // "1 item"
i18n.__('items', { count: 5 }); // "5 items"

// Select
i18n.__('invitation', { gender: 'female' }); 
// "She is invited"

// Number formatting
i18n.__('price', { amount: 1234.56 }); 
// "Price: $1,234.56"

// Date formatting
i18n.__('appointment', { date: new Date('2025-10-15') }); 
// "Your appointment is on 10/15/25"

API Reference

Exported Types

import {
  MessageFormatter,
  FormatterOptions,
  DefaultMessageFormatter,
} from 'meteor/universe:i18n';

Setting a Formatter

i18n.setOptions({
  messageFormatter: new MyCustomFormatter(),
});

Accessing the Current Formatter

const currentFormatter = i18n.options.messageFormatter;

Best Practices

  1. Cache Compiled Messages: If your formatter compiles messages (like ICU), cache the compiled results for better performance.

  2. Error Handling: Always handle errors gracefully and return the original message if formatting fails.

  3. Memory Management: For long-running applications, consider implementing cache size limits or LRU eviction.

  4. Type Safety: Use TypeScript to ensure your formatter implements the interface correctly.

  5. Testing: Write comprehensive tests for your custom formatter with various message patterns.

  6. Documentation: Document the message format syntax your formatter supports for other developers.

Migration from Default to Custom Format

If you're migrating from the default format to a custom format (e.g., ICU):

  1. Gradual Migration: You can use different formatters for different locales if needed by creating a wrapper formatter that delegates based on locale.

  2. Conversion Script: Write a script to convert your existing translation files to the new format.

  3. Testing: Thoroughly test all translations after migration to ensure nothing breaks.

  4. Fallback: Consider implementing a fallback mechanism that tries the new format first, then falls back to the old format if parsing fails.

Example: Hybrid Formatter

Here's an example of a formatter that supports both default and ICU formats:

import { MessageFormatter, FormatterOptions, DefaultMessageFormatter } from 'meteor/universe:i18n';
import { ICUMessageFormatter } from './ICUMessageFormatter';

export class HybridFormatter implements MessageFormatter {
  private defaultFormatter = new DefaultMessageFormatter();
  private icuFormatter = new ICUMessageFormatter();

  format(
    message: string,
    params: Record<string, unknown>,
    locale: string,
    options: FormatterOptions,
  ): string {
    // Detect format based on message syntax
    // ICU format typically uses {variable} without {$
    const isICU = message.includes('{') && !message.includes('{$');

    if (isICU) {
      return this.icuFormatter.format(message, params, locale, options);
    } else {
      return this.defaultFormatter.format(message, params, locale, options);
    }
  }
}

Changed the structure so that alternative formatters can be added.
Improved performance
@StorytellerCZ
Copy link
Member Author

I will try to integrate this in my app first to get experience in running this in a production app. In Literary Universe we use ICU message format so that is why I want this feature as migrating to the current custom default would be more trouble than it is worth.

Copy link
Member

@jankapunkt jankapunkt left a comment

Choose a reason for hiding this comment

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

Added comments for discussion but generally approved. Feel free to update the changes on your behalf. From my end they are not serious but nitpickings.

@jankapunkt jankapunkt requested a review from Copilot October 2, 2025 13:42
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds a pluggable message formatter system to universe:i18n, allowing developers to use custom message formats (e.g., ICU MessageFormat) while maintaining backward compatibility with the default format. The implementation also includes multiple performance optimizations using modern JavaScript APIs.

  • Introduces MessageFormatter interface and DefaultMessageFormatter class for customizable message formatting
  • Adds comprehensive test coverage for the new formatter functionality (8 new tests)
  • Implements significant performance improvements by replacing forEach loops with for...of, using replaceAll() instead of split().join(), and other optimizations

Reviewed Changes

Copilot reviewed 11 out of 18 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/common.ts Adds comprehensive test suite for custom message formatters with 8 new test cases
source/utils.ts Performance optimization: replaces shift() with index-based iteration
source/formatters/default.ts New default message formatter implementing the original universe:i18n format
source/formatters/base.ts Defines MessageFormatter interface and FormatterOptions for the formatter system
source/common.ts Integrates formatter system, adds performance optimizations, and refactors translation logic
source/code-generators.ts Performance optimization: uses Set for O(1) lookups instead of Array.includes()
source/client.ts Performance optimization: replaces forEach and map with for...of loops
package.js Updates package version to 3.1.0 and adds new formatter source files
README.md Updates feature list to mention pluggable message formatters
CUSTOM_FORMATTERS.md Comprehensive documentation for the new formatter system with examples
CHANGELOG.md Documents all changes and performance improvements in version 3.1.0

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

…tion

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
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.

3 participants